pax_global_header00006660000000000000000000000064140700444000014503gustar00rootroot0000000000000052 comment=79805d02c4fae473dac41e377b9c97c74e4cff92 weechat-matrix-0.3.0/000077500000000000000000000000001407004440000144255ustar00rootroot00000000000000weechat-matrix-0.3.0/.gitignore000066400000000000000000000001401407004440000164100ustar00rootroot00000000000000**/*.pyc **/.*\.py@neomake*\.py .hypothesis/ .mypy_cache/ .pytest_cache/ .ropeproject .coverage weechat-matrix-0.3.0/.pylintrc000066400000000000000000000374611407004440000163050ustar00rootroot00000000000000[MASTER] # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code extension-pkg-whitelist= # Add files or directories to the blacklist. They should be base names, not # paths. ignore=CVS # Add files or directories matching the regex patterns to the blacklist. The # regex matches against base names, not paths. ignore-patterns= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). #init-hook= # Use multiple processes to speed up Pylint. jobs=1 # List of plugins (as comma separated values of python modules names) to load, # usually to register additional checkers. load-plugins= # Pickle collected data for later comparisons. persistent=yes # Specify a configuration file. #rcfile= # When enabled, pylint would attempt to guess common misconfiguration and emit # user-friendly hints instead of false-positive error messages suggestion-mode=yes # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no [MESSAGES CONTROL] # Only show warnings with the listed confidence levels. Leave empty to show # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED confidence= # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifiers separated by comma (,) or put this # option multiple times (only on the command line, not in the configuration # file where it should appear only once).You can also use "--disable=all" to # disable everything first and then reenable specific checks. For example, if # you want to run only the similarities checker, you can use "--disable=all # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" disable=print-statement, parameter-unpacking, unpacking-in-except, old-raise-syntax, backtick, long-suffix, old-ne-operator, old-octal-literal, import-star-module-level, non-ascii-bytes-literal, raw-checker-failed, bad-inline-option, locally-disabled, locally-enabled, file-ignored, suppressed-message, useless-suppression, deprecated-pragma, apply-builtin, basestring-builtin, buffer-builtin, cmp-builtin, coerce-builtin, execfile-builtin, file-builtin, long-builtin, raw_input-builtin, reduce-builtin, standarderror-builtin, unicode-builtin, xrange-builtin, coerce-method, delslice-method, getslice-method, setslice-method, no-absolute-import, old-division, dict-iter-method, dict-view-method, next-method-called, metaclass-assignment, indexing-exception, raising-string, reload-builtin, oct-method, hex-method, nonzero-method, cmp-method, input-builtin, round-builtin, intern-builtin, unichr-builtin, map-builtin-not-iterating, zip-builtin-not-iterating, range-builtin-not-iterating, filter-builtin-not-iterating, using-cmp-argument, eq-without-hash, div-method, idiv-method, rdiv-method, exception-message-attribute, invalid-str-codec, sys-max-int, bad-python3-import, deprecated-string-function, deprecated-str-translate-call, deprecated-itertools-function, deprecated-types-field, next-method-defined, dict-items-not-iterating, dict-keys-not-iterating, dict-values-not-iterating, bad-whitespace, too-few-public-methods, too-many-lines, missing-docstring, bad-continuation, # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time (only on the command line, not in the configuration file where # it should appear only once). See also the "--disable" option for examples. enable=c-extension-no-member [REPORTS] # Python expression which should return a note less than 10 (10 is the highest # note). You have access to the variables errors warning, statement which # respectively contain the number of errors / warnings messages and the total # number of statements analyzed. This is used by the global evaluation report # (RP0004). evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) # Template used to display messages. This is a python new-style format string # used to format the message information. See doc for all details #msg-template= # Set the output format. Available formats are text, parseable, colorized, json # and msvs (visual studio).You can also give a reporter class, eg # mypackage.mymodule.MyReporterClass. output-format=text # Tells whether to display a full report or only the messages reports=no # Activate the evaluation score. score=yes [REFACTORING] # Maximum number of nested blocks for function / method body max-nested-blocks=5 [VARIABLES] # List of additional names supposed to be defined in builtins. Remember that # you should avoid to define new builtins when possible. additional-builtins= # Tells whether unused global variables should be treated as a violation. allow-global-unused-variables=yes # List of strings which can identify a callback function by name. A callback # name must start or end with one of those strings. callbacks=cb_, _cb # A regular expression matching the name of dummy variables (i.e. expectedly # not used). dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ # Argument names that match this expression will be ignored. Default to name # with leading underscore ignored-argument-names=_.*|^ignored_|^unused_ # Tells whether we should check for unused import in __init__ files. init-import=no # List of qualified module names which can have objects that can redefine # builtins. redefining-builtins-modules=six.moves,past.builtins,future.builtins [TYPECHECK] # List of decorators that produce context managers, such as # contextlib.contextmanager. Add to this list to register other decorators that # produce valid context managers. contextmanager-decorators=contextlib.contextmanager # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E1101 when accessed. Python regular # expressions are accepted. generated-members= # Tells whether missing members accessed in mixin class should be ignored. A # mixin class is detected if its name ends with "mixin" (case insensitive). ignore-mixin-members=yes # This flag controls whether pylint should warn about no-member and similar # checks whenever an opaque object is returned when inferring. The inference # can return multiple potential results while evaluating a Python object, but # some branches might not be evaluated, which results in partial inference. In # that case, it might be useful to still emit no-member and other checks for # the rest of the inferred objects. ignore-on-opaque-inference=yes # List of class names for which member attributes should not be checked (useful # for classes with dynamically set attributes). This supports the use of # qualified names. ignored-classes=optparse.Values,thread._local,_thread._local # List of module names for which member attributes should not be checked # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis. It # supports qualified module names, as well as Unix pattern matching. ignored-modules= # Show a hint with possible names when a member name was not found. The aspect # of finding the hint is based on edit distance. missing-member-hint=yes # The minimum edit distance a name should have in order to be considered a # similar match for a missing member name. missing-member-hint-distance=1 # The total number of similar names that should be taken in consideration when # showing a hint for a missing member. missing-member-max-choices=1 [SPELLING] # Limits count of emitted suggestions for spelling mistakes max-spelling-suggestions=4 # Spelling dictionary name. Available dictionaries: none. To make it working # install python-enchant package. spelling-dict= # List of comma separated words that should not be checked. spelling-ignore-words= # A path to a file that contains private dictionary; one word per line. spelling-private-dict-file= # Tells whether to store unknown words to indicated private dictionary in # --spelling-private-dict-file option instead of raising a message. spelling-store-unknown-words=no [SIMILARITIES] # Ignore comments when computing similarities. ignore-comments=yes # Ignore docstrings when computing similarities. ignore-docstrings=yes # Ignore imports when computing similarities. ignore-imports=no # Minimum lines number of a similarity. min-similarity-lines=4 [MISCELLANEOUS] # List of note tags to take in consideration, separated by a comma. notes=FIXME, XXX, TODO [LOGGING] # Logging modules to check that the string format arguments are in logging # function parameter format logging-modules=logging [FORMAT] # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. expected-line-ending-format= # Regexp for a line that is allowed to be longer than the limit. ignore-long-lines=^\s*(# )??$ # Number of spaces of indent required inside a hanging or continued line. indent-after-paren=4 # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 # tab). indent-string=' ' # Maximum number of characters on a single line. max-line-length=100 # Maximum number of lines in a module max-module-lines=1000 # List of optional constructs for which whitespace checking is disabled. `dict- # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. # `trailing-comma` allows a space between comma and closing bracket: (a, ). # `empty-line` allows space-only lines. no-space-check=trailing-comma, dict-separator # Allow the body of a class to be on the same line as the declaration if body # contains single statement. single-line-class-stmt=no # Allow the body of an if to be on the same line as the test if there is no # else. single-line-if-stmt=no [BASIC] # Naming style matching correct argument names argument-naming-style=snake_case # Regular expression matching correct argument names. Overrides argument- # naming-style #argument-rgx= # Naming style matching correct attribute names attr-naming-style=snake_case # Regular expression matching correct attribute names. Overrides attr-naming- # style #attr-rgx= # Bad variable names which should always be refused, separated by a comma bad-names=foo, bar, baz, toto, tutu, tata # Naming style matching correct class attribute names class-attribute-naming-style=any # Regular expression matching correct class attribute names. Overrides class- # attribute-naming-style #class-attribute-rgx= # Naming style matching correct class names class-naming-style=PascalCase # Regular expression matching correct class names. Overrides class-naming-style #class-rgx= # Naming style matching correct constant names const-naming-style=UPPER_CASE # Regular expression matching correct constant names. Overrides const-naming- # style #const-rgx= # Minimum line length for functions/classes that require docstrings, shorter # ones are exempt. docstring-min-length=-1 # Naming style matching correct function names function-naming-style=snake_case # Regular expression matching correct function names. Overrides function- # naming-style #function-rgx= # Good variable names which should always be accepted, separated by a comma good-names=i, j, k, ex, Run, _ # Include a hint for the correct naming format with invalid-name include-naming-hint=no # Naming style matching correct inline iteration names inlinevar-naming-style=any # Regular expression matching correct inline iteration names. Overrides # inlinevar-naming-style #inlinevar-rgx= # Naming style matching correct method names method-naming-style=snake_case # Regular expression matching correct method names. Overrides method-naming- # style #method-rgx= # Naming style matching correct module names module-naming-style=snake_case # Regular expression matching correct module names. Overrides module-naming- # style #module-rgx= # Colon-delimited sets of names that determine each other's naming style when # the name regexes allow several styles. name-group= # Regular expression which should only match function or class names that do # not require a docstring. no-docstring-rgx=^_ # List of decorators that produce properties, such as abc.abstractproperty. Add # to this list to register other decorators that produce valid properties. property-classes=abc.abstractproperty # Naming style matching correct variable names variable-naming-style=snake_case # Regular expression matching correct variable names. Overrides variable- # naming-style #variable-rgx= [IMPORTS] # Allow wildcard imports from modules that define __all__. allow-wildcard-with-all=no # Analyse import fallback blocks. This can be used to support both Python 2 and # 3 compatible code, which means that the block might have code that exists # only in one or another interpreter, leading to false positives when analysed. analyse-fallback-blocks=no # Deprecated modules which should not be used, separated by a comma deprecated-modules=optparse,tkinter.tix # Create a graph of external dependencies in the given file (report RP0402 must # not be disabled) ext-import-graph= # Create a graph of every (i.e. internal and external) dependencies in the # given file (report RP0402 must not be disabled) import-graph= # Create a graph of internal dependencies in the given file (report RP0402 must # not be disabled) int-import-graph= # Force import order to recognize a module as part of the standard # compatibility libraries. known-standard-library= # Force import order to recognize a module as part of a third party library. known-third-party=enchant [DESIGN] # Maximum number of arguments for function / method max-args=5 # Maximum number of attributes for a class (see R0902). max-attributes=7 # Maximum number of boolean expressions in a if statement max-bool-expr=5 # Maximum number of branch for function / method body max-branches=12 # Maximum number of locals for function / method body max-locals=15 # Maximum number of parents for a class (see R0901). max-parents=7 # Maximum number of public methods for a class (see R0904). max-public-methods=20 # Maximum number of return / yield for function / method body max-returns=6 # Maximum number of statements in function / method body max-statements=50 # Minimum number of public methods for a class (see R0903). min-public-methods=2 [CLASSES] # List of method names used to declare (i.e. assign) instance attributes. defining-attr-methods=__init__, __new__, setUp # List of member names, which should be excluded from the protected access # warning. exclude-protected=_asdict, _fields, _replace, _source, _make # List of valid names for the first argument in a class method. valid-classmethod-first-arg=cls # List of valid names for the first argument in a metaclass class method. valid-metaclass-classmethod-first-arg=mcs [EXCEPTIONS] # Exceptions that will emit a warning when being caught. Defaults to # "Exception" overgeneral-exceptions=Exception weechat-matrix-0.3.0/.travis.yml000066400000000000000000000007741407004440000165460ustar00rootroot00000000000000--- ## Machine config os: "linux" arch: "amd64" dist: "bionic" version: "~> 1.0" ## Language config language: "python" python: - "3.6" - "3.7" - "3.8" before_install: - wget https://gitlab.matrix.org/matrix-org/olm/-/archive/master/olm-master.tar.bz2 - tar -xvf olm-master.tar.bz2 - pushd olm-master && make && sudo make PREFIX="/usr" install && popd - rm -r olm-master install: - pip install -r requirements.txt - pip install pytest - pip install hypothesis script: python -m pytest weechat-matrix-0.3.0/Dockerfile000066400000000000000000000014041407004440000164160ustar00rootroot00000000000000FROM debian:testing-slim RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections RUN apt-get update -y; apt-get install -q -y \ git \ libolm-dev \ python3 \ python3-pip \ weechat-curses \ weechat-python \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ && rm -fr /root/.cache # add chat user RUN useradd -ms /bin/bash chat && mkdir /var/build # get and build source code WORKDIR /var/build RUN git clone https://github.com/poljar/weechat-matrix.git WORKDIR /var/build/weechat-matrix RUN pip3 install -r requirements.txt # Install and setup autoloading USER chat RUN make install WORKDIR /home/chat RUN mkdir -p .weechat/python/autoload && ln -s /home/chat/.weechat/python/matrix.py /home/chat/.weechat/python/autoload/ weechat-matrix-0.3.0/LICENSE000066400000000000000000000014201407004440000154270ustar00rootroot00000000000000Weechat Matrix Protocol Script Copyright © 2018 Damir Jelić Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. weechat-matrix-0.3.0/Makefile000066400000000000000000000017571407004440000160770ustar00rootroot00000000000000.PHONY: install install-lib install-dir uninstall phony test typecheck WEECHAT_HOME ?= $(HOME)/.weechat PREFIX ?= $(WEECHAT_HOME) PYTHON ?= python INSTALLDIR := $(DESTDIR)$(PREFIX)/python/matrix lib := $(patsubst matrix/%.py, $(INSTALLDIR)/%.py, \ $(wildcard matrix/*.py)) .PHONY: help help: @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' install: install-lib | $(INSTALLDIR) ## Install the plugin to $(DESTDIR)/$(PREFIX) install -m644 main.py $(DESTDIR)$(PREFIX)/python/matrix.py install-lib: $(lib) $(INSTALLDIR): install -d $@ uninstall: ## Uninstall the plugin from $(PREFIX) rm $(DESTDIR)$(PREFIX)/python/matrix.py $(INSTALLDIR)/* rmdir $(INSTALLDIR) phony: $(INSTALLDIR)/%.py: matrix/%.py phony | $(INSTALLDIR) install -m644 $< $@ test: ## Run automated tests python3 -m pytest python2 -m pytest typecheck: ## Run type check mypy -p matrix --ignore-missing-imports --warn-redundant-casts weechat-matrix-0.3.0/README.md000066400000000000000000000254101407004440000157060ustar00rootroot00000000000000[![Build Status](https://img.shields.io/travis/poljar/weechat-matrix.svg?style=flat-square)](https://travis-ci.org/poljar/weechat-matrix) [![#weechat-matrix](https://img.shields.io/badge/matrix-%23weechat--matrix:termina.org.uk-blue.svg?style=flat-square)](https://matrix.to/#/!twcBhHVdZlQWuuxBhN:termina.org.uk?via=termina.org.uk&via=matrix.org) [![license](https://img.shields.io/badge/license-ISC-blue.svg?style=flat-square)](https://github.com/poljar/weechat-matrix/blob/master/LICENSE) # What is Weechat-Matrix? [Weechat](https://weechat.org/) is an extensible chat client. [Matrix](https://matrix.org/blog/home) is an open network for secure, decentralized communication. [weechat-matrix](https://github.com/poljar/weechat-matrix/) is a Python plugin for Weechat that lets Weechat communicate over the Matrix protocol. # Project Status weechat-matrix is stable and quite usable as a daily driver. It already supports large parts of the Matrix protocol, including end-to-end encryption (though some features like cross-signing and session unwedging are unimplemented). However, due to some inherent limitations of Weechat *scripts*, development has moved to [weechat-matrix-rs](https://github.com/poljar/weechat-matrix-rs), a Weechat *plugin* written in Rust. As such, weechat-matrix is in maintenance mode and will likely not be receiving substantial new features. PRs are still accepted and welcome. # Installation 1. Install libolm 3.1+ - Debian 11+ (testing/sid) or Ubuntu 19.10+ install libolm-dev - Archlinux based distribution can install the `libolm` package from the Community repository - FreeBSD `pkg install olm` - macOS `brew install libolm` - Failing any of the above see https://gitlab.matrix.org/matrix-org/olm for instructions about building it from sources 2. Clone the repo and install dependencies ``` git clone https://github.com/poljar/weechat-matrix.git cd weechat-matrix pip install -r requirements.txt ``` 3. As your regular user, just run: `make install` in this repository directory. This installs the main python file (`main.py`) into `~/.weechat/python/` (renamed to `matrix.py`) along with the other python files it needs (from the `matrix` subdir). Note that weechat only supports Python2 OR Python3, and that setting is determined at the time that Weechat is compiled. Weechat-Matrix can work with either Python2 or Python3, but when you install dependencies you will have to take into account which version of Python your Weechat was built to use. The minimal supported python2 version is 2.7.10. The minimal supported python3 version is 3.5.4 or 3.6.1. To check the python version that weechat is using, run: /python version ## Using virtualenv If you want to install dependencies inside a virtualenv, rather than globally for your system or user, you can use a virtualenv. Weechat-Matrix will automatically use any virtualenv it finds in a directory called `venv` next to its main Python file (after resolving symlinks). Typically, this means `~/.weechat/python/venv`. To create such a virtualenv, you can use something like below. This only needs to happen once: ``` virtualenv ~/.weechat/python/venv ``` Then, activate the virtualenv: ``` . ~/.weechat/python/venv/bin/activate ``` This needs to be done whenever you want to install packages inside the virtualenv (so before running the `pip install` command documented above. Once the virtualenv is prepared in the right location, Weechat-Matrix will automatically activate it when the plugin is loaded. This should not affect other plugins, which seem to have a separate Python environment. Note that this only supports virtualenv tools that support the [`activate_this.py` way of activation](https://virtualenv.pypa.io/en/latest/userguide/#using-virtualenv-without-bin-python). This includes the `virtualenv` command, but excludes pyvenv and the Python3 `venv` module. In particular, this works if (for a typical installation of `matrix.py`) the file `~/.weechat/python/venv/bin/activate_this.py` exists. ## Run from git directly Rather than copying files into `~/.weechat` (step 3 above), it is also possible to run from a git checkout directly using symlinks. For this, you need two symlinks: ``` ln -s /path/to/weechat-matrix/main.py ~/.weechat/python/matrix.py ln -s /path/to/weechat-matrix/matrix ~/.weechat/python/matrix ``` This first link is the main python file, that can be loaded using `/script load matrix.py`. The second link is to the directory with extra python files used by the main script. This directory must be linked as `~/.weechat/python/matrix` so it ends up in the python library path and its files can be imported using e.g. `import matrix` from the main python file. Note that these symlinks are essentially the same as the files that would have been copied using `make install`. ## Uploading files Uploads are done using a helper script, which is found under [contrib/matrix_upload](https://github.com/poljar/weechat-matrix/blob/master/contrib/matrix_upload.py). We recommend you install this under your `PATH` as `matrix_upload` (without the `.py` suffix). ## Downloading encrypted files Encrypted files can be opened by passing the displayed `emxc://` URI to the [contrib/matrix_decrypt](https://github.com/poljar/weechat-matrix/blob/master/contrib/matrix_decrypt.py) helper script. # Configuration Configuration is completed primarily through the Weechat interface. First start Weechat, and then issue the following commands: 1. Start by loading the Weechat-Matrix plugin: /script load matrix.py 2. Now set your username and password: /set matrix.server.matrix_org.username johndoe /set matrix.server.matrix_org.password jd_is_awesome 3. Now try to connect: /matrix connect matrix_org 4. Automatically load the script $ ln -s ../matrix.py ~/.weechat/python/autoload 5. Automatically connect to the server /set matrix.server.matrix_org.autoconnect on 6. If everything works, save the configuration /save ## For using a custom (not matrix.org) matrix server: 1. Add your custom server to the plugin: /matrix server add myserver myserver.org 1. Add the appropriate credentials /set matrix.server.myserver.username johndoe /set matrix.server.myserver.password jd_is_awesome 1. If everything works, save the configuration /save ## Single sign-on: Single sign-on is supported using a helper script, the script found under [contrib/matrix_sso_helper](https://github.com/poljar/weechat-matrix/blob/master/contrib/matrix_sso_helper.py) should be installed under your `PATH` as `matrix_sso_helper` (without the `.py` suffix). For single sign-on to be the preferred leave the servers username and password empty. After connecting a URL will be presented which needs to be used to perform the sign on. Please note that the helper script spawns a HTTP server which waits for the sign-on token to be passed back. This makes it necessary to do the sign on on the same host as Weechat. A hsignal is sent out when the SSO helper spawns as well, the name of the hsignal is `matrix_sso_login` and it will contain the name of the server in the `server` variable and the full URL that can be used to log in in the `url` variable. To open the login URL automatically in a browser a trigger can be added: /trigger add sso_browser hsignal matrix_sso_login "" "" "/exec -bg firefox ${url}" If signing on on the same host as Weechat is undesirable the listening port of the SSO helper should be set to a static value using the `sso_helper_listening_port` setting: /set matrix.server.myserver.sso_helper_listening_port 8443 After setting the listening port the same port on the local machine can be forwarded using ssh to the remote host: ssh -L 8443:localhost:8443 example.org This forwards the local port 8443 to the localhost:8443 address on example.org. Note that it is necessary to forward the port to the localhost address on the remote host because the helper only listens on localhost. ## Bar items There are two bar items provided by this script: 1. `matrix_typing_notice` - shows the currently typing users 1. `matrix_modes` - shows room and server info (encryption status of the room, server connection status) They can be added to the weechat status bar as usual: /set weechat.bar.status.items The `matrix_modes` bar item is replicated in the already used `buffer_modes` bar item. ## Typing notifications and read receipts The sending of typing notifications and read receipts can be temporarily disabled for a given room via the `/room` command. They can also be permanently configured using standard weechat conditions settings with the following settings: 1. `matrix.network.read_markers_conditions` 1. `matrix.network.typing_notice_conditions` ## Cursor bindings While you can reply on a matrix message using the `/reply-matrix` command (see its help in weechat), weechat-matrix also adds a binding in `/cursor` mode to easily reply to a particular message. This mode can be triggered either by running `/cursor`, or by middle-clicking somewhere on the screen. See weechat's help for `/cursor`. The default binding is: /key bindctxt cursor @chat(python.matrix.*):r hsignal:matrix_cursor_reply This means that you can reply to a message in a Matrix buffer using the middle mouse button, then `r`. This binding is automatically set when the script is loaded and there is no such binding yet. If you want to use a different key than `r`, you can execute the above command with a different key in place of `r`. To use modifier keys like control and alt, use alt-k, then your wanted binding key combo, to enter weechat's representation of that key combo in the input bar. ## Navigating room buffers using go.py If you try to use the `go.py` script to navigate buffers created by weechat-matrix, `go.py` will by default use the full buffer name which does not contain a human-readable room display name but only the Matrix room ID. This is necessary so that the logger file is able to produce unique, permanent filenames for a room. However, buffers also have human-readable short names. To make `go.py` use the short names for navigation, you can run the following command: ``` /set plugins.var.python.go.short_name "on" ``` As an alternative, you can also force weechat-matrix to use human-readable names as the full buffer names by running ``` /set matrix.look.human_buffer_names on ``` Beware that you will then also need to adjust your logger setup to prevent room name conflicts from causing logger file conflicts. # Helpful Commands `/help matrix` will print information about the `/matrix` command. `/help olm` will print information about the `/olm` command that is used for device verification. `/matrix help [command]` will print information for subcommands, such as `/matrix help server` weechat-matrix-0.3.0/contrib/000077500000000000000000000000001407004440000160655ustar00rootroot00000000000000weechat-matrix-0.3.0/contrib/matrix_decrypt.py000077500000000000000000000051641407004440000215060ustar00rootroot00000000000000#!/usr/bin/env python3 # matrix_decrypt - Download and decrypt an encrypted attachment # from a matrix server # Copyright © 2019 Damir Jelić # # Permission to use, copy, modify, and/or distribute this software for # any purpose with or without fee is hereby granted, provided that the # above copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. import argparse import requests import tempfile import subprocess from urllib.parse import urlparse, parse_qs from nio.crypto import decrypt_attachment def save_file(data): """Save data to a temporary file and return its name.""" tmp_dir = tempfile.gettempdir() with tempfile.NamedTemporaryFile( prefix='plumber-', dir=tmp_dir, delete=False ) as f: f.write(data) f.flush() return f.name def main(): parser = argparse.ArgumentParser( description='Download and decrypt matrix attachments' ) parser.add_argument('url', help='the url of the attachment') parser.add_argument('file', nargs='?', help='save attachment to ') parser.add_argument('--plumber', help='program that gets called with the ' 'dowloaded file') args = parser.parse_args() url = urlparse(args.url) query = parse_qs(url.query) if not query["key"] or not query["iv"] or not query["hash"]: print("Missing decryption argument") return -1 key = query["key"][0] iv = query["iv"][0] hash = query["hash"][0] http_url = "https://{}{}".format(url.netloc, url.path) request = requests.get(http_url) if not request.ok: print("Error downloading file") return -2 plumber = args.plumber plaintext = decrypt_attachment(request.content, key, hash, iv) if args.file is None: file_name = save_file(plaintext) if plumber is None: plumber = "xdg-open" else: file_name = args.file open(file_name, "wb").write(plaintext) if plumber is not None: subprocess.run([plumber, file_name]) return 0 if __name__ == "__main__": main() weechat-matrix-0.3.0/contrib/matrix_sso_helper.py000077500000000000000000000062461407004440000222010ustar00rootroot00000000000000#!/usr/bin/env -S python3 -u # Copyright 2019 The Matrix.org Foundation CIC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import asyncio import argparse import socket import json from random import choice from aiohttp import web # The browsers ban some known ports, the dynamic port range doesn't contain any # banned ports, so we use that. port_range = range(49152, 65535) shutdown_task = None def to_weechat(message): print(json.dumps(message)) async def get_token(request): global shutdown_task async def shutdown(): await asyncio.sleep(1) raise KeyboardInterrupt token = request.query.get("loginToken") if not token: raise KeyboardInterrupt message = { "type": "token", "loginToken": token } # Send the token to weechat. to_weechat(message) # Initiate a shutdown. shutdown_task = asyncio.ensure_future(shutdown()) # Respond to the browser. return web.Response(text="Continuing in Weechat.") def bind_socket(port=None): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) if port is not None and port != 0: sock.bind(("localhost", port)) return sock while True: port = choice(port_range) try: sock.bind(("localhost", port)) except OSError: continue return sock async def wait_for_shutdown_task(_): if not shutdown_task: return try: await shutdown_task except KeyboardInterrupt: pass def main(): parser = argparse.ArgumentParser( description="Start a web server that waits for a SSO token to be " "passed with a GET request" ) parser.add_argument( "-p", "--port", help=("the port that the web server will be listening on, if 0 a " "random port should be chosen" ), type=int, default=0 ) args = parser.parse_args() app = web.Application() app.add_routes([web.get('/', get_token)]) if not 0 <= args.port <= 65535: raise ValueError("Port needs to be 0-65535") try: sock = bind_socket(args.port) except OSError as e: message = { "type": "error", "message": str(e), "code": e.errno } to_weechat(message) return host, port = sock.getsockname() sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) message = { "type": "redirectUrl", "host": host, "port": port } to_weechat(message) app.on_shutdown.append(wait_for_shutdown_task) web.run_app(app, sock=sock, handle_signals=True, print=None) if __name__ == "__main__": main() weechat-matrix-0.3.0/contrib/matrix_upload.py000077500000000000000000000177771407004440000213350ustar00rootroot00000000000000#!/usr/bin/env -S python3 -u # Copyright © 2018 Damir Jelić # # Permission to use, copy, modify, and/or distribute this software for # any purpose with or without fee is hereby granted, provided that the # above copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. import os import json import magic import requests import argparse from urllib.parse import urlparse from itertools import zip_longest import urllib3 from nio import Api, UploadResponse, UploadError from nio.crypto import encrypt_attachment from json.decoder import JSONDecodeError urllib3.disable_warnings() def to_stdout(message): print(json.dumps(message), flush=True) def error(e): message = { "type": "status", "status": "error", "message": str(e) } to_stdout(message) os.sys.exit() def mime_from_file(file): try: t = magic.from_file(file, mime=True) except AttributeError: try: m = magic.open(magic.MIME) m.load() t, _ = m.file(file).split(';') except AttributeError: error('Your \'magic\' module is unsupported. ' 'Install either https://github.com/ahupp/python-magic ' 'or https://github.com/file/file/tree/master/python ' '(official \'file\' python bindings, available as the ' 'python-magic package on many distros)') raise SystemExit return t class Upload(object): def __init__(self, file, chunksize=1 << 13): self.file = file self.filename = os.path.basename(file) self.chunksize = chunksize self.totalsize = os.path.getsize(file) self.mimetype = mime_from_file(file) self.readsofar = 0 def send_progress(self): message = { "type": "progress", "data": self.readsofar } to_stdout(message) def __iter__(self): with open(self.file, 'rb') as file: while True: data = file.read(self.chunksize) if not data: break self.readsofar += len(data) self.send_progress() yield data def __len__(self): return self.totalsize def chunk_bytes(iterable, n): args = [iter(iterable)] * n return ( bytes( (filter(lambda x: x is not None, chunk)) ) for chunk in zip_longest(*args) ) class EncryptedUpload(Upload): def __init__(self, file, chunksize=1 << 13): super().__init__(file, chunksize) self.source_mimetype = self.mimetype self.mimetype = "application/octet-stream" with open(self.file, "rb") as file: self.ciphertext, self.file_keys = encrypt_attachment(file.read()) def send_progress(self): message = { "type": "progress", "data": self.readsofar } to_stdout(message) def __iter__(self): for chunk in chunk_bytes(self.ciphertext, self.chunksize): self.readsofar += len(chunk) self.send_progress() yield chunk def __len__(self): return len(self.ciphertext) class IterableToFileAdapter(object): def __init__(self, iterable): self.iterator = iter(iterable) self.length = len(iterable) def read(self, size=-1): return next(self.iterator, b'') def __len__(self): return self.length def upload_process(args): file_path = os.path.expanduser(args.file) thumbnail = None try: if args.encrypt: upload = EncryptedUpload(file_path) if upload.source_mimetype.startswith("image"): # TODO create a thumbnail thumbnail = None else: upload = Upload(file_path) except (FileNotFoundError, OSError, IOError) as e: error(e) try: url = urlparse(args.homeserver) except ValueError as e: error(e) upload_url = ("https://{}".format(args.homeserver) if not url.scheme else args.homeserver) _, api_path, _ = Api.upload(args.access_token, upload.filename) upload_url += api_path headers = { "Content-type": upload.mimetype, } proxies = {} if args.proxy_address: user = args.proxy_user or "" if args.proxy_password: user += ":{}".format(args.proxy_password) if user: user += "@" proxies = { "https": "{}://{}{}:{}/".format( args.proxy_type, user, args.proxy_address, args.proxy_port ) } message = { "type": "status", "status": "started", "total": upload.totalsize, "file_name": upload.filename, } if isinstance(upload, EncryptedUpload): message["mimetype"] = upload.source_mimetype else: message["mimetype"] = upload.mimetype to_stdout(message) session = requests.Session() session.trust_env = False try: r = session.post( url=upload_url, auth=None, headers=headers, data=IterableToFileAdapter(upload), verify=(not args.insecure), proxies=proxies ) except (requests.exceptions.RequestException, OSError) as e: error(e) try: json_response = json.loads(r.content) except JSONDecodeError: error(r.content) response = UploadResponse.from_dict(json_response) if isinstance(response, UploadError): error(str(response)) message = { "type": "status", "status": "done", "url": response.content_uri } if isinstance(upload, EncryptedUpload): message["file_keys"] = upload.file_keys to_stdout(message) return 0 def main(): parser = argparse.ArgumentParser( description="Encrypt and upload matrix attachments" ) parser.add_argument("file", help="the file that will be uploaded") parser.add_argument( "homeserver", type=str, help="the address of the homeserver" ) parser.add_argument( "access_token", type=str, help="the access token to use for the upload" ) parser.add_argument( "--encrypt", action="store_const", const=True, default=False, help="encrypt the file before uploading it" ) parser.add_argument( "--insecure", action="store_const", const=True, default=False, help="disable SSL certificate verification" ) parser.add_argument( "--proxy-type", choices=[ "http", "socks4", "socks5" ], default="http", help="type of the proxy that will be used to establish a connection" ) parser.add_argument( "--proxy-address", type=str, help="address of the proxy that will be used to establish a connection" ) parser.add_argument( "--proxy-port", type=int, default=8080, help="port of the proxy that will be used to establish a connection" ) parser.add_argument( "--proxy-user", type=str, help="user that will be used for authentication on the proxy" ) parser.add_argument( "--proxy-password", type=str, help="password that will be used for authentication on the proxy" ) args = parser.parse_args() upload_process(args) if __name__ == "__main__": main() weechat-matrix-0.3.0/main.py000066400000000000000000000566031407004440000157350ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Weechat Matrix Protocol Script # Copyright © 2018 Damir Jelić # # Permission to use, copy, modify, and/or distribute this software for # any purpose with or without fee is hereby granted, provided that the # above copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. from __future__ import unicode_literals import os # See if there is a `venv` directory next to our script, and use that if # present. This first resolves symlinks, so this also works when we are # loaded through a symlink (e.g. from autoload). # See https://virtualenv.pypa.io/en/latest/userguide/#using-virtualenv-without-bin-python # This does not support pyvenv or the python3 venv module, which do not # create an activate_this.py: https://stackoverflow.com/questions/27462582 activate_this = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'venv', 'bin', 'activate_this.py') if os.path.exists(activate_this): exec(open(activate_this).read(), {'__file__': activate_this}) import socket import ssl import textwrap # pylint: disable=redefined-builtin from builtins import str from itertools import chain # pylint: disable=unused-import from typing import Any, AnyStr, Deque, Dict, List, Optional, Set, Text, Tuple import logbook import json import OpenSSL.crypto as crypto from future.utils import bytes_to_native_str as n from logbook import Logger, StreamHandler try: from json.decoder import JSONDecodeError except ImportError: JSONDecodeError = ValueError # type: ignore from nio import RemoteProtocolError, RemoteTransportError, TransportType from matrix import globals as G from matrix.bar_items import ( init_bar_items, matrix_bar_item_buffer_modes, matrix_bar_item_lag, matrix_bar_item_name, matrix_bar_item_plugin, matrix_bar_nicklist_count, matrix_bar_typing_notices_cb ) from matrix.buffer import room_buffer_close_cb, room_buffer_input_cb # Weechat searches for the registered callbacks in the scope of the main script # file, import the callbacks here so weechat can find them. from matrix.commands import (hook_commands, hook_key_bindings, hook_page_up, matrix_command_buf_clear_cb, matrix_command_cb, matrix_command_pgup_cb, matrix_invite_command_cb, matrix_join_command_cb, matrix_kick_command_cb, matrix_me_command_cb, matrix_part_command_cb, matrix_redact_command_cb, matrix_topic_command_cb, matrix_olm_command_cb, matrix_devices_command_cb, matrix_room_command_cb, matrix_uploads_command_cb, matrix_upload_command_cb, matrix_send_anyways_cb, matrix_reply_command_cb, matrix_cursor_reply_signal_cb) from matrix.completion import (init_completion, matrix_command_completion_cb, matrix_debug_completion_cb, matrix_message_completion_cb, matrix_olm_device_completion_cb, matrix_olm_user_completion_cb, matrix_server_command_completion_cb, matrix_server_completion_cb, matrix_user_completion_cb, matrix_own_devices_completion_cb, matrix_room_completion_cb) from matrix.config import (MatrixConfig, config_log_category_cb, config_log_level_cb, config_server_buffer_cb, matrix_config_reload_cb, config_pgup_cb) from matrix.globals import SCRIPT_NAME, SERVERS, W from matrix.server import (MatrixServer, create_default_server, matrix_config_server_change_cb, matrix_config_server_read_cb, matrix_config_server_write_cb, matrix_timer_cb, send_cb, matrix_load_users_cb) from matrix.utf import utf8_decode from matrix.utils import server_buffer_prnt, server_buffer_set_title from matrix.uploads import UploadsBuffer, upload_cb try: from urllib.parse import urlunparse except ImportError: from urlparse import urlunparse # yapf: disable WEECHAT_SCRIPT_NAME = SCRIPT_NAME WEECHAT_SCRIPT_DESCRIPTION = "matrix chat plugin" # type: str WEECHAT_SCRIPT_AUTHOR = "Damir Jelić " # type: str WEECHAT_SCRIPT_VERSION = "0.3.0" # type: str WEECHAT_SCRIPT_LICENSE = "ISC" # type: str # yapf: enable logger = Logger("matrix-cli") def print_certificate_info(buff, sock, cert): cert_pem = ssl.DER_cert_to_PEM_cert(sock.getpeercert(True)) x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert_pem) public_key = x509.get_pubkey() key_type = ("RSA" if public_key.type() == crypto.TYPE_RSA else "DSA") key_size = str(public_key.bits()) sha256_fingerprint = x509.digest(n(b"SHA256")) sha1_fingerprint = x509.digest(n(b"SHA1")) signature_algorithm = x509.get_signature_algorithm() key_info = ("key info: {key_type} key {bits} bits, signed using " "{algo}").format( key_type=key_type, bits=key_size, algo=n(signature_algorithm)) validity_info = (" Begins on: {before}\n" " Expires on: {after}").format( before=cert["notBefore"], after=cert["notAfter"]) rdns = chain(*cert["subject"]) subject = ", ".join(["{}={}".format(name, value) for name, value in rdns]) rdns = chain(*cert["issuer"]) issuer = ", ".join(["{}={}".format(name, value) for name, value in rdns]) subject = "subject: {sub}, serial number {serial}".format( sub=subject, serial=cert["serialNumber"]) issuer = "issuer: {issuer}".format(issuer=issuer) fingerprints = (" SHA1: {}\n" " SHA256: {}").format(n(sha1_fingerprint), n(sha256_fingerprint)) wrapper = textwrap.TextWrapper( initial_indent=" - ", subsequent_indent=" ") message = ("{prefix}matrix: received certificate\n" " - certificate info:\n" "{subject}\n" "{issuer}\n" "{key_info}\n" " - period of validity:\n{validity_info}\n" " - fingerprints:\n{fingerprints}").format( prefix=W.prefix("network"), subject=wrapper.fill(subject), issuer=wrapper.fill(issuer), key_info=wrapper.fill(key_info), validity_info=validity_info, fingerprints=fingerprints) W.prnt(buff, message) def wrap_socket(server, file_descriptor): # type: (MatrixServer, int) -> None sock = None # type: socket.socket temp_socket = socket.fromfd(file_descriptor, socket.AF_INET, socket.SOCK_STREAM) # fromfd() duplicates the file descriptor, we can close the one we got from # weechat now since we use the one from our socket when calling hook_fd() os.close(file_descriptor) # For python 2.7 wrap_socket() doesn't work with sockets created from an # file descriptor because fromfd() doesn't return a wrapped socket, the bug # was fixed for python 3, more info: https://bugs.python.org/issue13942 # pylint: disable=protected-access,unidiomatic-typecheck if type(temp_socket) == socket._socket.socket: # pylint: disable=no-member sock = socket._socketobject(_sock=temp_socket) else: sock = temp_socket # fromfd() duplicates the file descriptor but doesn't retain it's blocking # non-blocking attribute, so mark the socket as non-blocking even though # weechat already did that for us sock.setblocking(False) message = "{prefix}matrix: Doing SSL handshake...".format( prefix=W.prefix("network")) W.prnt(server.server_buffer, message) ssl_socket = server.ssl_context.wrap_socket( sock, do_handshake_on_connect=False, server_hostname=server.address) # type: ssl.SSLSocket server.socket = ssl_socket try_ssl_handshake(server) @utf8_decode def ssl_fd_cb(server_name, file_descriptor): server = SERVERS[server_name] if server.ssl_hook: W.unhook(server.ssl_hook) server.ssl_hook = None try_ssl_handshake(server) return W.WEECHAT_RC_OK def try_ssl_handshake(server): sock = server.socket while True: try: sock.do_handshake() cipher = sock.cipher() cipher_message = ("{prefix}matrix: Connected using {tls}, and " "{bit} bit {cipher} cipher suite.").format( prefix=W.prefix("network"), tls=cipher[1], bit=cipher[2], cipher=cipher[0]) W.prnt(server.server_buffer, cipher_message) cert = sock.getpeercert() if cert: print_certificate_info(server.server_buffer, sock, cert) finalize_connection(server) return True except ssl.SSLWantReadError: hook = W.hook_fd(server.socket.fileno(), 1, 0, 0, "ssl_fd_cb", server.name) server.ssl_hook = hook return False except ssl.SSLWantWriteError: hook = W.hook_fd(server.socket.fileno(), 0, 1, 0, "ssl_fd_cb", server.name) server.ssl_hook = hook return False except (ssl.SSLError, ssl.CertificateError, socket.error) as error: try: str_error = error.reason if error.reason else "Unknown error" except AttributeError: str_error = str(error) message = ("{prefix}Error while doing SSL handshake" ": {error}").format( prefix=W.prefix("network"), error=str_error) server_buffer_prnt(server, message) server_buffer_prnt( server, ("{prefix}matrix: disconnecting from server..." ).format(prefix=W.prefix("network"))) server.disconnect() return False @utf8_decode def receive_cb(server_name, file_descriptor): server = SERVERS[server_name] while True: try: data = server.socket.recv(4096) except ssl.SSLWantReadError: break except socket.error as error: errno = "error" + str(error.errno) + " " if error.errno else "" str_error = error.strerror if error.strerror else "Unknown error" str_error = errno + str_error message = ("{prefix}Error while reading from " "socket: {error}").format( prefix=W.prefix("network"), error=str_error) server_buffer_prnt(server, message) server_buffer_prnt( server, ("{prefix}matrix: disconnecting from server..." ).format(prefix=W.prefix("network"))) server.disconnect() return W.WEECHAT_RC_OK if not data: server_buffer_prnt( server, "{prefix}matrix: Error while reading from socket".format( prefix=W.prefix("network"))) server_buffer_prnt( server, ("{prefix}matrix: disconnecting from server..." ).format(prefix=W.prefix("network"))) server.disconnect() break try: server.client.receive(data) except (RemoteTransportError, RemoteProtocolError) as e: server.error(str(e)) server.disconnect() break response = server.client.next_response() # Check if we need to send some data back data_to_send = server.client.data_to_send() if data_to_send: server.send(data_to_send) if response: server.handle_response(response) break return W.WEECHAT_RC_OK def finalize_connection(server): hook = W.hook_fd( server.socket.fileno(), 1, 0, 0, "receive_cb", server.name ) server.fd_hook = hook server.connected = True server.connecting = False server.reconnect_delay = 0 negotiated_protocol = (server.socket.selected_alpn_protocol() or server.socket.selected_npn_protocol()) if negotiated_protocol == "h2": server.transport_type = TransportType.HTTP2 else: server.transport_type = TransportType.HTTP data = server.client.connect(server.transport_type) server.send(data) server.login_info() @utf8_decode def sso_login_cb(server_name, command, return_code, out, err): try: server = SERVERS[server_name] except KeyError: message = ( "{}{}: SSO callback ran, but no server for it was found.").format( W.prefix("error"), SCRIPT_NAME) W.prnt("", message) if return_code == W.WEECHAT_HOOK_PROCESS_ERROR: server.error("Error while running the matrix_sso_helper. Please " "make sure that the helper script is executable and can " "be found in your PATH.") server.sso_hook = None server.disconnect() return W.WEECHAT_RC_OK # The child process exited mark the hook as done. if return_code == 0: server.sso_hook = None if err != "": W.prnt("", "stderr: %s" % err) if out == "": return W.WEECHAT_RC_OK try: ret = json.loads(out) msgtype = ret.get("type") if msgtype == "redirectUrl": redirect_url = "http://{}:{}".format(ret["host"], ret["port"]) login_url = ( "{}/_matrix/client/r0/login/sso/redirect?redirectUrl={}" ).format(server.homeserver.geturl(), redirect_url) server.info_highlight( "The server requested a single sign-on, please open " "this URL in your browser. Note that the " "browser needs to run on the same host as Weechat.") server.info_highlight(login_url) message = { "server": server.name, "url": login_url } W.hook_hsignal_send("matrix_sso_login", message) elif msgtype == "token": token = ret["loginToken"] server.login(token=token) elif msgtype == "error": server.error("Error in the SSO helper {}".format(ret["message"])) else: server.error("Unknown SSO login message received from child " "process.") except JSONDecodeError: server.error( "Error decoding SSO login message from child process: {}".format( out )) return W.WEECHAT_RC_OK @utf8_decode def connect_cb(data, status, gnutls_rc, sock, error, ip_address): # pylint: disable=too-many-arguments,too-many-branches status_value = int(status) # type: int server = SERVERS[data] if status_value == W.WEECHAT_HOOK_CONNECT_OK: file_descriptor = int(sock) # type: int server.numeric_address = ip_address server_buffer_set_title(server) wrap_socket(server, file_descriptor) return W.WEECHAT_RC_OK elif status_value == W.WEECHAT_HOOK_CONNECT_ADDRESS_NOT_FOUND: server.error('{address} not found'.format(address=ip_address)) elif status_value == W.WEECHAT_HOOK_CONNECT_IP_ADDRESS_NOT_FOUND: server.error('IP address not found') elif status_value == W.WEECHAT_HOOK_CONNECT_CONNECTION_REFUSED: server.error('Connection refused') elif status_value == W.WEECHAT_HOOK_CONNECT_PROXY_ERROR: server.error('Proxy fails to establish connection to server') elif status_value == W.WEECHAT_HOOK_CONNECT_LOCAL_HOSTNAME_ERROR: server.error('Unable to set local hostname') elif status_value == W.WEECHAT_HOOK_CONNECT_GNUTLS_INIT_ERROR: server.error('TLS init error') elif status_value == W.WEECHAT_HOOK_CONNECT_GNUTLS_HANDSHAKE_ERROR: server.error('TLS Handshake failed') elif status_value == W.WEECHAT_HOOK_CONNECT_MEMORY_ERROR: server.error('Not enough memory') elif status_value == W.WEECHAT_HOOK_CONNECT_TIMEOUT: server.error('Timeout') elif status_value == W.WEECHAT_HOOK_CONNECT_SOCKET_ERROR: server.error('Unable to create socket') else: server.error('Unexpected error: {status}'.format(status=status_value)) server.disconnect(reconnect=True) return W.WEECHAT_RC_OK @utf8_decode def room_close_cb(data, buffer): W.prnt("", "Buffer '%s' will be closed!" % W.buffer_get_string(buffer, "name")) return W.WEECHAT_RC_OK @utf8_decode def matrix_unload_cb(): for server in SERVERS.values(): server.config.free() G.CONFIG.free() return W.WEECHAT_RC_OK def autoconnect(servers): for server in servers.values(): if server.config.autoconnect: server.connect() def debug_buffer_close_cb(data, buffer): G.CONFIG.debug_buffer = "" return W.WEECHAT_RC_OK def server_buffer_cb(server_name, buffer, input_data): message = ("{}{}: this buffer is not a room buffer!").format( W.prefix("error"), SCRIPT_NAME) W.prnt(buffer, message) return W.WEECHAT_RC_OK class WeechatHandler(StreamHandler): def __init__(self, level=logbook.NOTSET, format_string=None, filter=None, bubble=False): StreamHandler.__init__( self, object(), level, format_string, None, filter, bubble ) def write(self, item): buf = "" if G.CONFIG.network.debug_buffer: if not G.CONFIG.debug_buffer: G.CONFIG.debug_buffer = W.buffer_new( "Matrix Debug", "", "", "debug_buffer_close_cb", "") buf = G.CONFIG.debug_buffer W.prnt(buf, item) def buffer_switch_cb(_, _signal, buffer_ptr): """Do some buffer operations when we switch buffers. This function is called every time we switch a buffer. The pointer of the new buffer is given to us by weechat. If it is one of our room buffers we check if the members for the room aren't fetched and fetch them now if they aren't. Read receipts are send out from here as well. """ for server in SERVERS.values(): if buffer_ptr == server.server_buffer: return W.WEECHAT_RC_OK if buffer_ptr not in server.buffers.values(): continue room_buffer = server.find_room_from_ptr(buffer_ptr) if not room_buffer: continue last_event_id = room_buffer.last_event_id if room_buffer.should_send_read_marker: # A buffer may not have any events, in that case no event id is # here returned if last_event_id: server.room_send_read_marker( room_buffer.room.room_id, last_event_id) room_buffer.last_read_event = last_event_id if not room_buffer.members_fetched: room_id = room_buffer.room.room_id server.get_joined_members(room_id) # The buffer is empty and we are seeing it for the first time. # Let us fetch some messages from the room history so it doesn't feel so # empty. if room_buffer.first_view and room_buffer.weechat_buffer.num_lines < 10: # TODO we may want to fetch 10 - num_lines messages here for # consistency reasons. server.room_get_messages(room_buffer.room.room_id) break return W.WEECHAT_RC_OK def typing_notification_cb(data, signal, buffer_ptr): """Send out typing notifications if the user is typing. This function is called every time the input text is changed. It checks if we are on a buffer we own, and if we are sends out a typing notification if the room is configured to send them out. """ for server in SERVERS.values(): room_buffer = server.find_room_from_ptr(buffer_ptr) if room_buffer: server.room_send_typing_notice(room_buffer) return W.WEECHAT_RC_OK if buffer_ptr == server.server_buffer: return W.WEECHAT_RC_OK return W.WEECHAT_RC_OK def buffer_command_cb(data, _, command): """Override the buffer command to allow switching buffers by short name.""" command = command[7:].strip() buffer_subcommands = ["list", "add", "clear", "move", "swap", "cycle", "merge", "unmerge", "hide", "unhide", "renumber", "close", "notify", "localvar", "set", "get"] if not command: return W.WEECHAT_RC_OK command_words = command.split() if len(command_words) >= 1: if command_words[0] in buffer_subcommands: return W.WEECHAT_RC_OK elif command_words[0].startswith("*"): return W.WEECHAT_RC_OK try: int(command_words[0]) return W.WEECHAT_RC_OK except ValueError: pass room_buffers = [] for server in SERVERS.values(): room_buffers.extend(server.room_buffers.values()) sorted_buffers = sorted( room_buffers, key=lambda b: b.weechat_buffer.number ) for room_buffer in sorted_buffers: buffer = room_buffer.weechat_buffer if command in buffer.short_name: displayed = W.current_buffer() == buffer._ptr if displayed: continue W.buffer_set(buffer._ptr, 'display', '1') return W.WEECHAT_RC_OK_EAT return W.WEECHAT_RC_OK if __name__ == "__main__": if W.register(WEECHAT_SCRIPT_NAME, WEECHAT_SCRIPT_AUTHOR, WEECHAT_SCRIPT_VERSION, WEECHAT_SCRIPT_LICENSE, WEECHAT_SCRIPT_DESCRIPTION, 'matrix_unload_cb', ''): if not W.mkdir_home("matrix", 0o700): message = ("{prefix}matrix: Error creating session " "directory").format(prefix=W.prefix("error")) W.prnt("", message) handler = WeechatHandler() handler.format_string = "{record.channel}: {record.message}" handler.push_application() # TODO if this fails we should abort and unload the script. G.CONFIG = MatrixConfig() G.CONFIG.read() hook_commands() hook_key_bindings() init_bar_items() init_completion() W.hook_command_run("/buffer", "buffer_command_cb", "") W.hook_signal("buffer_switch", "buffer_switch_cb", "") W.hook_signal("input_text_changed", "typing_notification_cb", "") if not SERVERS: create_default_server(G.CONFIG) autoconnect(SERVERS) weechat-matrix-0.3.0/matrix/000077500000000000000000000000001407004440000157315ustar00rootroot00000000000000weechat-matrix-0.3.0/matrix/__init__.py000066400000000000000000000000001407004440000200300ustar00rootroot00000000000000weechat-matrix-0.3.0/matrix/_weechat.py000066400000000000000000000137661407004440000200770ustar00rootroot00000000000000import datetime import random import string WEECHAT_BASE_COLORS = { "black": "0", "red": "1", "green": "2", "brown": "3", "blue": "4", "magenta": "5", "cyan": "6", "default": "7", "gray": "8", "lightred": "9", "lightgreen": "10", "yellow": "11", "lightblue": "12", "lightmagenta": "13", "lightcyan": "14", "white": "15" } class MockObject(object): pass class MockConfig(object): config_template = { 'debug_buffer': None, 'debug_category': None, '_ptr': None, 'read': None, 'free': None, 'page_up_hook': None, 'color': { 'error_message_bg': "", 'error_message_fg': "", 'quote_bg': "", 'quote_fg': "", 'unconfirmed_message_bg': "", 'unconfirmed_message_fg': "", 'untagged_code_bg': "", 'untagged_code_fg': "", }, 'upload_buffer': { 'display': None, 'move_line_down': None, 'move_line_up': None, 'render': None, }, 'look': { 'bar_item_typing_notice_prefix': None, 'busy_sign': None, 'code_block_margin': None, 'code_blocks': None, 'disconnect_sign': None, 'encrypted_room_sign': None, 'encryption_warning_sign': None, 'max_typing_notice_item_length': None, 'pygments_style': None, 'redactions': None, 'server_buffer': None, 'new_channel_position': None, 'markdown_input': True, }, 'network': { 'debug_buffer': None, 'debug_category': None, 'debug_level': None, 'fetch_backlog_on_pgup': None, 'lag_min_show': None, 'lag_reconnect': None, 'lazy_load_room_users': None, 'max_initial_sync_events': None, 'max_nicklist_users': None, 'print_unconfirmed_messages': None, 'read_markers_conditions': None, 'typing_notice_conditions': None, 'autoreconnect_delay_growing': None, 'autoreconnect_delay_max': None, }, } def __init__(self): for category, options in MockConfig.config_template.items(): if options: category_object = MockObject() for option, value in options.items(): setattr(category_object, option, value) else: category_object = options setattr(self, category, category_object) def color(color_name): # type: (str) -> str # yapf: disable escape_codes = [] reset_code = "0" def make_fg_color(color_code): return "38;5;{}".format(color_code) def make_bg_color(color_code): return "48;5;{}".format(color_code) attributes = { "bold": "1", "-bold": "21", "reverse": "27", "-reverse": "21", "italic": "3", "-italic": "23", "underline": "4", "-underline": "24", "reset": "0", "resetcolor": "39" } short_attributes = { "*": "1", "!": "27", "/": "3", "_": "4" } colors = color_name.split(",", 2) fg_color = colors.pop(0) bg_color = colors.pop(0) if colors else "" if fg_color in attributes: escape_codes.append(attributes[fg_color]) else: chars = list(fg_color) for char in chars: if char in short_attributes: escape_codes.append(short_attributes[char]) elif char == "|": reset_code = "" else: break stripped_color = fg_color.lstrip("*_|/!") if stripped_color in WEECHAT_BASE_COLORS: escape_codes.append( make_fg_color(WEECHAT_BASE_COLORS[stripped_color])) elif stripped_color.isdigit(): num_color = int(stripped_color) if 0 <= num_color < 256: escape_codes.append(make_fg_color(stripped_color)) if bg_color in WEECHAT_BASE_COLORS: escape_codes.append(make_bg_color(WEECHAT_BASE_COLORS[bg_color])) else: if bg_color.isdigit(): num_color = int(bg_color) if 0 <= num_color < 256: escape_codes.append(make_bg_color(bg_color)) escape_string = "\033[{}{}m".format(reset_code, ";".join(escape_codes)) return escape_string def prefix(prefix_string): prefix_to_symbol = { "error": "=!=", "network": "--", "action": "*", "join": "-->", "quit": "<--" } if prefix_string in prefix_to_symbol: return prefix_to_symbol[prefix] return "" def prnt(_, message): print(message) def prnt_date_tags(_, date, tags_string, data): message = "{} {} [{}]".format( datetime.datetime.fromtimestamp(date), data, tags_string ) print(message) def config_search_section(*_, **__): pass def config_new_option(*_, **__): pass def mkdir_home(*_, **__): return True def info_get(info, *_): if info == "nick_color_name": return random.choice(list(WEECHAT_BASE_COLORS.keys())) return "" def buffer_new(*_, **__): return "".join( random.choice(string.ascii_uppercase + string.digits) for _ in range(8) ) def buffer_set(*_, **__): return def buffer_get_string(_ptr, property): if property == "localvar_type": return "channel" return "" def buffer_get_integer(_ptr, property): return 0 def current_buffer(): return 1 def nicklist_add_group(*_, **__): return def nicklist_add_nick(*_, **__): return def nicklist_remove_nick(*_, **__): return def nicklist_search_nick(*args, **kwargs): return buffer_new(args, kwargs) def string_remove_color(message, _): return message weechat-matrix-0.3.0/matrix/bar_items.py000066400000000000000000000153621407004440000202570ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Copyright © 2018, 2019 Damir Jelić # # Permission to use, copy, modify, and/or distribute this software for # any purpose with or without fee is hereby granted, provided that the # above copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. from __future__ import unicode_literals from . import globals as G from .globals import SERVERS, W from .utf import utf8_decode @utf8_decode def matrix_bar_item_plugin(data, item, window, buffer, extra_info): # pylint: disable=unused-argument for server in SERVERS.values(): if buffer in server.buffers.values() or buffer == server.server_buffer: return "matrix{color}/{color_fg}{name}".format( color=W.color("bar_delim"), color_fg=W.color("bar_fg"), name=server.name, ) ptr_plugin = W.buffer_get_pointer(buffer, "plugin") name = W.plugin_get_name(ptr_plugin) return name @utf8_decode def matrix_bar_item_name(data, item, window, buffer, extra_info): # pylint: disable=unused-argument for server in SERVERS.values(): if buffer in server.buffers.values(): color = ( "status_name_ssl" if server.ssl_context.check_hostname else "status_name" ) room_buffer = server.find_room_from_ptr(buffer) room = room_buffer.room return "{color}{name}".format( color=W.color(color), name=room.display_name ) if buffer == server.server_buffer: color = ( "status_name_ssl" if server.ssl_context.check_hostname else "status_name" ) return "{color}server{del_color}[{color}{name}{del_color}]".format( color=W.color(color), del_color=W.color("bar_delim"), name=server.name, ) name = W.buffer_get_string(buffer, "name") return "{}{}".format(W.color("status_name"), name) @utf8_decode def matrix_bar_item_lag(data, item, window, buffer, extra_info): # pylint: disable=unused-argument for server in SERVERS.values(): if buffer in server.buffers.values() or buffer == server.server_buffer: if server.lag >= G.CONFIG.network.lag_min_show: color = W.color("irc.color.item_lag_counting") if server.lag_done: color = W.color("irc.color.item_lag_finished") lag = "{0:.3f}" if round(server.lag) < 1000 else "{0:.0f}" lag_string = "Lag: {color}{lag}{ncolor}".format( lag=lag.format((server.lag / 1000)), color=color, ncolor=W.color("reset"), ) return lag_string return "" return "" @utf8_decode def matrix_bar_item_buffer_modes(data, item, window, buffer, extra_info): # pylint: disable=unused-argument for server in SERVERS.values(): if buffer in server.buffers.values(): room_buffer = server.find_room_from_ptr(buffer) room = room_buffer.room modes = [] if room.encrypted: modes.append(G.CONFIG.look.encrypted_room_sign) if (server.client and server.client.room_contains_unverified(room.room_id)): modes.append(G.CONFIG.look.encryption_warning_sign) if not server.connected or not server.client.logged_in: modes.append(G.CONFIG.look.disconnect_sign) if room_buffer.backlog_pending or server.busy: modes.append(G.CONFIG.look.busy_sign) return "".join(modes) return "" @utf8_decode def matrix_bar_nicklist_count(data, item, window, buffer, extra_info): # pylint: disable=unused-argument color = W.color("status_nicklist_count") for server in SERVERS.values(): if buffer in server.buffers.values(): room_buffer = server.find_room_from_ptr(buffer) room = room_buffer.room return "{}{}".format(color, room.member_count) nicklist_enabled = bool(W.buffer_get_integer(buffer, "nicklist")) if nicklist_enabled: nick_count = W.buffer_get_integer(buffer, "nicklist_visible_count") return "{}{}".format(color, nick_count) return "" @utf8_decode def matrix_bar_typing_notices_cb(data, item, window, buffer, extra_info): """Update a status bar item showing users currently typing. This function is called by weechat every time a buffer is switched or W.bar_item_update() is explicitly called. The bar item shows currently typing users for the current buffer.""" # pylint: disable=unused-argument for server in SERVERS.values(): if buffer in server.buffers.values(): room_buffer = server.find_room_from_ptr(buffer) room = room_buffer.room if room.typing_users: nicks = [] for user_id in room.typing_users: if user_id == room.own_user_id: continue nick = room_buffer.displayed_nicks.get(user_id, user_id) nicks.append(nick) if not nicks: return "" msg = "{}{}".format( G.CONFIG.look.bar_item_typing_notice_prefix, ", ".join(sorted(nicks)) ) max_len = G.CONFIG.look.max_typing_notice_item_length if len(msg) > max_len: msg[:max_len - 3] + "..." return msg return "" return "" def init_bar_items(): W.bar_item_new("(extra)buffer_plugin", "matrix_bar_item_plugin", "") W.bar_item_new("(extra)buffer_name", "matrix_bar_item_name", "") W.bar_item_new("(extra)lag", "matrix_bar_item_lag", "") W.bar_item_new( "(extra)buffer_nicklist_count", "matrix_bar_nicklist_count", "" ) W.bar_item_new( "(extra)matrix_typing_notice", "matrix_bar_typing_notices_cb", "" ) W.bar_item_new("(extra)buffer_modes", "matrix_bar_item_buffer_modes", "") W.bar_item_new("(extra)matrix_modes", "matrix_bar_item_buffer_modes", "") weechat-matrix-0.3.0/matrix/buffer.py000066400000000000000000001656421407004440000175720ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Weechat Matrix Protocol Script # Copyright © 2018, 2019 Damir Jelić # # Permission to use, copy, modify, and/or distribute this software for # any purpose with or without fee is hereby granted, provided that the # above copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. from __future__ import unicode_literals import time import attr import pprint from builtins import super from functools import partial from collections import deque from typing import Dict, List, NamedTuple, Optional, Set from uuid import UUID from nio import ( Api, PowerLevelsEvent, RedactedEvent, RedactionEvent, RoomAliasEvent, RoomEncryptionEvent, RoomMemberEvent, RoomMessage, RoomMessageEmote, RoomMessageMedia, RoomEncryptedMedia, RoomMessageNotice, RoomMessageText, RoomMessageUnknown, RoomNameEvent, RoomTopicEvent, MegolmEvent, Event, OlmTrustError, UnknownEvent, FullyReadEvent, BadEvent, UnknownBadEvent, ) from . import globals as G from .colors import Formatted from .config import RedactType, NewChannelPosition from .globals import SCRIPT_NAME, SERVERS, W, TYPING_NOTICE_TIMEOUT from .utf import utf8_decode from .message_renderer import Render from .utils import ( server_ts_to_weechat, shorten_sender, string_strikethrough, color_pair, ) @attr.s class OwnMessages(object): sender = attr.ib(type=str) age = attr.ib(type=int) event_id = attr.ib(type=str) uuid = attr.ib(type=str) room_id = attr.ib(type=str) formatted_message = attr.ib(type=Formatted) class OwnMessage(OwnMessages): pass class OwnAction(OwnMessage): pass @utf8_decode def room_buffer_input_cb(server_name, buffer, input_data): server = SERVERS[server_name] room_buffer = server.find_room_from_ptr(buffer) if not room_buffer: # TODO log error return W.WEECHAT_RC_ERROR if not server.connected: room_buffer.error("You are not connected to the server") return W.WEECHAT_RC_ERROR if not server.client.logged_in: room_buffer.error("You are not logged in.") return W.WEECHAT_RC_ERROR data = W.string_input_for_buffer(input_data) if not data: data = input_data formatted_data = Formatted.from_input_line(data) try: server.room_send_message(room_buffer, formatted_data, "m.text") room_buffer.last_message = None except OlmTrustError as e: if (G.CONFIG.network.resending_ignores_devices and room_buffer.last_message): room_buffer.error("Ignoring unverified devices.") if (room_buffer.last_message.to_weechat() == formatted_data.to_weechat()): server.room_send_message(room_buffer, formatted_data, "m.text", ignore_unverified_devices=True) room_buffer.last_message = None else: # If the item is a normal user message store it in the # buffer to enable the send-anyways functionality. room_buffer.error("Untrusted devices found in room: {}".format(e)) room_buffer.last_message = formatted_data return W.WEECHAT_RC_OK @utf8_decode def room_buffer_close_cb(server_name, buffer): server = SERVERS[server_name] room_buffer = server.find_room_from_ptr(buffer) if room_buffer: room_id = room_buffer.room.room_id server.buffers.pop(room_id, None) server.room_buffers.pop(room_id, None) return W.WEECHAT_RC_OK class WeechatUser(object): def __init__(self, nick, host=None, prefix="", join_time=None): # type: (str, str, str, int) -> None self.nick = nick self.host = host self.prefix = prefix self.color = W.info_get("nick_color_name", nick) self.join_time = join_time or time.time() self.speaking_time = None # type: Optional[int] def update_speaking_time(self, new_time=None): self.speaking_time = new_time or time.time() @property def joined_recently(self): # TODO make the delay configurable delay = 30 limit = time.time() - (delay * 60) return self.join_time < limit @property def spoken_recently(self): if not self.speaking_time: return False # TODO make the delay configurable delay = 5 limit = time.time() - (delay * 60) return self.speaking_time < limit class RoomUser(WeechatUser): def __init__(self, nick, user_id=None, power_level=0, join_time=None): # type: (str, str, int, int) -> None prefix = self._get_prefix(power_level) super().__init__(nick, user_id, prefix, join_time) @property def power_level(self): # This shouldn't be used since it's a lossy function. It's only here # for the setter if self.prefix == "&": return 100 if self.prefix == "@": return 50 if self.prefix == "+": return 10 return 0 @power_level.setter def power_level(self, level): self.prefix = self._get_prefix(level) @staticmethod def _get_prefix(power_level): # type: (int) -> str if power_level >= 100: return "&" if power_level >= 50: return "@" if power_level > 0: return "+" return "" class WeechatChannelBuffer(object): tags = { "message": [SCRIPT_NAME + "_message", "notify_message", "log1"], "message_private": [ SCRIPT_NAME + "_message", "notify_private", "log1" ], "self_message": [ SCRIPT_NAME + "_message", "notify_none", "no_highlight", "self_msg", "log1", ], "action": [ SCRIPT_NAME + "_message", SCRIPT_NAME + "_action", "notify_message", "log1", ], "action_private": [ SCRIPT_NAME + "_message", SCRIPT_NAME + "_action", "notify_private", "log1", ], "notice": [SCRIPT_NAME + "_notice", "notify_message", "log1"], "old_message": [ SCRIPT_NAME + "_message", "notify_message", "no_log", "no_highlight", ], "join": [SCRIPT_NAME + "_join", "log4"], "part": [SCRIPT_NAME + "_leave", "log4"], "kick": [SCRIPT_NAME + "_kick", "log4"], "invite": [SCRIPT_NAME + "_invite", "log4"], "topic": [SCRIPT_NAME + "_topic", "log3"], } membership_messages = { "join": "has joined", "part": "has left", "kick": "has been kicked from", "invite": "has been invited to", } class Line(object): def __init__(self, pointer): self._ptr = pointer @property def _hdata(self): return W.hdata_get("line_data") @property def prefix(self): return W.hdata_string(self._hdata, self._ptr, "prefix") @prefix.setter def prefix(self, new_prefix): new_data = {"prefix": new_prefix} W.hdata_update(self._hdata, self._ptr, new_data) @property def message(self): return W.hdata_string(self._hdata, self._ptr, "message") @message.setter def message(self, new_message): # type: (str) -> None new_data = {"message": new_message} W.hdata_update(self._hdata, self._ptr, new_data) @property def tags(self): tags_count = W.hdata_get_var_array_size( self._hdata, self._ptr, "tags_array" ) tags = [ W.hdata_string(self._hdata, self._ptr, "%d|tags_array" % i) for i in range(tags_count) ] return tags @tags.setter def tags(self, new_tags): # type: (List[str]) -> None new_data = {"tags_array": ",".join(new_tags)} W.hdata_update(self._hdata, self._ptr, new_data) @property def date(self): # type: () -> int return W.hdata_time(self._hdata, self._ptr, "date") @date.setter def date(self, new_date): # type: (int) -> None new_data = {"date": str(new_date)} W.hdata_update(self._hdata, self._ptr, new_data) @property def date_printed(self): # type: () -> int return W.hdata_time(self._hdata, self._ptr, "date_printed") @date_printed.setter def date_printed(self, new_date): # type: (int) -> None new_data = {"date_printed": str(new_date)} W.hdata_update(self._hdata, self._ptr, new_data) @property def highlight(self): # type: () -> bool return bool(W.hdata_char(self._hdata, self._ptr, "highlight")) def update( self, date=None, date_printed=None, tags=None, prefix=None, message=None, highlight=None, ): new_data = {} if date is not None: new_data["date"] = str(date) if date_printed is not None: new_data["date_printed"] = str(date_printed) if tags is not None: new_data["tags_array"] = ",".join(tags) if prefix is not None: new_data["prefix"] = prefix if message is not None: new_data["message"] = message if highlight is not None: new_data["highlight"] = highlight if new_data: W.hdata_update(self._hdata, self._ptr, new_data) def __init__(self, name, server_name, user): # type: (str, str, str) -> None # Previous buffer num before create cur_num = W.buffer_get_integer(W.current_buffer(), "number") self._ptr = W.buffer_new( name, "room_buffer_input_cb", server_name, "room_buffer_close_cb", server_name, ) new_channel_position = G.CONFIG.look.new_channel_position if new_channel_position == NewChannelPosition.NONE: pass elif new_channel_position == NewChannelPosition.NEXT: self.number = cur_num + 1 elif new_channel_position == NewChannelPosition.NEAR_SERVER: server = G.SERVERS[server_name] last_similar_buffer_num = max( (room.weechat_buffer.number for room in server.room_buffers.values()), default=W.buffer_get_integer(server.server_buffer, "number") ) self.number = last_similar_buffer_num + 1 self.name = "" self.users = {} # type: Dict[str, WeechatUser] self.smart_filtered_nicks = set() # type: Set[str] self.topic_author = "" self.topic_date = None W.buffer_set(self._ptr, "localvar_set_type", "private") W.buffer_set(self._ptr, "type", "formatted") W.buffer_set(self._ptr, "localvar_set_channel", name) W.buffer_set(self._ptr, "localvar_set_nick", user) W.buffer_set(self._ptr, "localvar_set_server", server_name) W.nicklist_add_group( self._ptr, "", "000|o", "weechat.color.nicklist_group", 1 ) W.nicklist_add_group( self._ptr, "", "001|h", "weechat.color.nicklist_group", 1 ) W.nicklist_add_group( self._ptr, "", "002|v", "weechat.color.nicklist_group", 1 ) W.nicklist_add_group( self._ptr, "", "999|...", "weechat.color.nicklist_group", 1 ) W.buffer_set(self._ptr, "nicklist", "1") W.buffer_set(self._ptr, "nicklist_display_groups", "0") W.buffer_set(self._ptr, "highlight_words", user) # TODO make this configurable W.buffer_set( self._ptr, "highlight_tags_restrict", SCRIPT_NAME + "_message" ) @property def _hdata(self): return W.hdata_get("buffer") def add_smart_filtered_nick(self, nick): self.smart_filtered_nicks.add(nick) def remove_smart_filtered_nick(self, nick): self.smart_filtered_nicks.discard(nick) def unmask_smart_filtered_nick(self, nick): if nick not in self.smart_filtered_nicks: return for line in self.lines: filtered = False join = False tags = line.tags if "nick_{}".format(nick) not in tags: continue if SCRIPT_NAME + "_smart_filter" in tags: filtered = True elif SCRIPT_NAME + "_join" in tags: join = True if filtered: tags.remove(SCRIPT_NAME + "_smart_filter") line.tags = tags if join: break self.remove_smart_filtered_nick(nick) @property def input(self): # type: () -> str """Get the bar item input text of the buffer.""" return W.buffer_get_string(self._ptr, "input") @property def num_lines(self): own_lines = W.hdata_pointer(self._hdata, self._ptr, "own_lines") return W.hdata_integer(W.hdata_get("lines"), own_lines, "lines_count") @property def lines(self): own_lines = W.hdata_pointer(self._hdata, self._ptr, "own_lines") if own_lines: hdata_line = W.hdata_get("line") line_pointer = W.hdata_pointer( W.hdata_get("lines"), own_lines, "last_line" ) while line_pointer: data_pointer = W.hdata_pointer( hdata_line, line_pointer, "data" ) if data_pointer: yield WeechatChannelBuffer.Line(data_pointer) line_pointer = W.hdata_move(hdata_line, line_pointer, -1) def _print(self, string): # type: (str) -> None """ Print a string to the room buffer """ W.prnt(self._ptr, string) def print_date_tags(self, data, date=None, tags=None): # type: (str, Optional[int], Optional[List[str]]) -> None date = date or int(time.time()) tags = tags or [] tags_string = ",".join(tags) W.prnt_date_tags(self._ptr, date, tags_string, data) def error(self, string): # type: (str) -> None """ Print an error to the room buffer """ message = "{prefix}{script}: {message}".format( prefix=W.prefix("error"), script=SCRIPT_NAME, message=string ) self._print(message) def info(self, string): message = "{prefix}{script}: {message}".format( prefix=W.prefix("network"), script=SCRIPT_NAME, message=string ) self._print(message) @staticmethod def _color_for_tags(color): # type: (str) -> str if color == "weechat.color.chat_nick_self": option = W.config_get(color) return W.config_string(option) return color def _message_tags(self, user, message_type): # type: (WeechatUser, str) -> List[str] tags = list(self.tags[message_type]) tags.append("nick_{nick}".format(nick=user.nick)) color = self._color_for_tags(user.color) if message_type not in ("action", "notice"): tags.append("prefix_nick_{color}".format(color=color)) return tags def _get_user(self, nick): # type: (str) -> WeechatUser if nick in self.users: return self.users[nick] # A message from a non joined user return WeechatUser(nick) def _print_message(self, user, message, date, tags, extra_prefix=""): prefix_string = ( extra_prefix if not user.prefix else "{}{}{}{}".format( extra_prefix, W.color(self._get_prefix_color(user.prefix)), user.prefix, W.color("reset"), ) ) data = "{prefix}{color}{author}{ncolor}\t{msg}".format( prefix=prefix_string, color=W.color(user.color), author=user.nick, ncolor=W.color("reset"), msg=message, ) self.print_date_tags(data, date, tags) def message(self, nick, message, date, extra_tags=None, extra_prefix=""): # type: (str, str, int, List[str], str) -> None user = self._get_user(nick) tags_type = "message_private" if self.type == "private" else "message" tags = self._message_tags(user, tags_type) + (extra_tags or []) self._print_message(user, message, date, tags, extra_prefix) user.update_speaking_time(date) self.unmask_smart_filtered_nick(nick) def notice(self, nick, message, date, extra_tags=None, extra_prefix=""): # type: (str, str, int, Optional[List[str]], str) -> None user = self._get_user(nick) user_prefix = ( "" if not user.prefix else "{}{}{}".format( W.color(self._get_prefix_color(user.prefix)), user.prefix, W.color("reset"), ) ) user_string = "{}{}{}{}".format( user_prefix, W.color(user.color), user.nick, W.color("reset") ) data = ( "{extra_prefix}{prefix}{color}Notice" "{del_color}({ncolor}{user}{del_color}){ncolor}" ": {message}" ).format( extra_prefix=extra_prefix, prefix=W.prefix("network"), color=W.color("irc.color.notice"), del_color=W.color("chat_delimiters"), ncolor=W.color("reset"), user=user_string, message=message, ) tags = self._message_tags(user, "notice") + (extra_tags or []) self.print_date_tags(data, date, tags) user.update_speaking_time(date) self.unmask_smart_filtered_nick(nick) def _format_action(self, user, message): nick_prefix = ( "" if not user.prefix else "{}{}{}".format( W.color(self._get_prefix_color(user.prefix)), user.prefix, W.color("reset"), ) ) data = ( "{nick_prefix}{nick_color}{author}" "{ncolor} {msg}").format( nick_prefix=nick_prefix, nick_color=W.color(user.color), author=user.nick, ncolor=W.color("reset"), msg=message, ) return data def _print_action(self, user, message, date, tags, extra_prefix=""): data = self._format_action(user, message) data = "{extra_prefix}{prefix}{data}".format( extra_prefix=extra_prefix, prefix=W.prefix("action"), data=data) self.print_date_tags(data, date, tags) def action(self, nick, message, date, extra_tags=None, extra_prefix=""): # type: (str, str, int, Optional[List[str]], str) -> None user = self._get_user(nick) tags_type = "action_private" if self.type == "private" else "action" tags = self._message_tags(user, tags_type) + (extra_tags or []) self._print_action(user, message, date, tags, extra_prefix) user.update_speaking_time(date) self.unmask_smart_filtered_nick(nick) @staticmethod def _get_nicklist_group(user): # type: (WeechatUser) -> str group_name = "999|..." if user.prefix == "&": group_name = "000|o" elif user.prefix == "@": group_name = "001|h" elif user.prefix == "+": group_name = "002|v" return group_name @staticmethod def _get_prefix_color(prefix): # type: (str) -> str return G.CONFIG.color.nick_prefixes.get(prefix, "") def _add_user_to_nicklist(self, user): # type: (WeechatUser) -> None nick_pointer = W.nicklist_search_nick(self._ptr, "", user.nick) if not nick_pointer: group = W.nicklist_search_group( self._ptr, "", self._get_nicklist_group(user) ) prefix = user.prefix if user.prefix else " " W.nicklist_add_nick( self._ptr, group, user.nick, user.color, prefix, self._get_prefix_color(user.prefix), 1, ) def _membership_message(self, user, message_type): # type: (WeechatUser, str) -> str action_color = "green" if message_type in ("join", "invite") else "red" prefix = "join" if message_type in ("join", "invite") else "quit" membership_message = self.membership_messages[message_type] message = ( "{prefix}{color}{author}{ncolor} " "{del_color}({host_color}{host}{del_color})" "{action_color} {message} " "{channel_color}{room}{ncolor}" ).format( prefix=W.prefix(prefix), color=W.color(user.color), author=user.nick, ncolor=W.color("reset"), del_color=W.color("chat_delimiters"), host_color=W.color("chat_host"), host=user.host, action_color=W.color(action_color), message=membership_message, channel_color=W.color("chat_channel"), room=self.short_name, ) return message def join(self, user, date, message=True, extra_tags=None): # type: (WeechatUser, int, Optional[bool], Optional[List[str]]) -> None self._add_user_to_nicklist(user) self.users[user.nick] = user if len(self.users) > 2: W.buffer_set(self._ptr, "localvar_set_type", "channel") if message: tags = self._message_tags(user, "join") msg = self._membership_message(user, "join") # TODO add a option to disable smart filters tags.append(SCRIPT_NAME + "_smart_filter") self.print_date_tags(msg, date, tags) self.add_smart_filtered_nick(user.nick) def invite(self, nick, date, extra_tags=None): # type: (str, int, Optional[List[str]]) -> None user = self._get_user(nick) tags = self._message_tags(user, "invite") message = self._membership_message(user, "invite") self.print_date_tags(message, date, tags + (extra_tags or [])) def remove_user_from_nicklist(self, user): # type: (WeechatUser) -> None nick_pointer = W.nicklist_search_nick(self._ptr, "", user.nick) if nick_pointer: W.nicklist_remove_nick(self._ptr, nick_pointer) def _leave(self, nick, date, message, leave_type, extra_tags=None): # type: (str, int, bool, str, List[str]) -> None user = self._get_user(nick) self.remove_user_from_nicklist(user) if len(self.users) <= 2: W.buffer_set(self._ptr, "localvar_set_type", "private") if message: tags = self._message_tags(user, leave_type) # TODO make this configurable if not user.spoken_recently: tags.append(SCRIPT_NAME + "_smart_filter") msg = self._membership_message(user, leave_type) self.print_date_tags(msg, date, tags + (extra_tags or [])) self.remove_smart_filtered_nick(user.nick) if user.nick in self.users: del self.users[user.nick] def part(self, nick, date, message=True, extra_tags=None): # type: (str, int, bool, Optional[List[str]]) -> None self._leave(nick, date, message, "part", extra_tags) def kick(self, nick, date, message=True, extra_tags=None): # type: (str, int, bool, Optional[List[str]]) -> None self._leave(nick, date, message, "kick", extra_tags) def _print_topic(self, nick, topic, date): user = self._get_user(nick) tags = self._message_tags(user, "topic") data = ( "{prefix}{nick} has changed " "the topic for {chan_color}{room}{ncolor} " 'to "{topic}"' ).format( prefix=W.prefix("network"), nick=user.nick, chan_color=W.color("chat_channel"), ncolor=W.color("reset"), room=self.short_name, topic=topic, ) self.print_date_tags(data, date, tags) user.update_speaking_time(date) self.unmask_smart_filtered_nick(nick) @property def topic(self): return W.buffer_get_string(self._ptr, "title") @topic.setter def topic(self, topic): W.buffer_set(self._ptr, "title", topic) def change_topic(self, nick, topic, date, message=True): if message: self._print_topic(nick, topic, date) self.topic = topic self.topic_author = nick self.topic_date = date def self_message(self, nick, message, date, tags=None): user = self._get_user(nick) tags = self._message_tags(user, "self_message") + (tags or []) self._print_message(user, message, date, tags) def self_action(self, nick, message, date, tags=None): user = self._get_user(nick) tags = self._message_tags(user, "self_message") + (tags or []) tags.append(SCRIPT_NAME + "_action") self._print_action(user, message, date, tags) @property def type(self): return W.buffer_get_string(self._ptr, "localvar_type") @property def short_name(self): return W.buffer_get_string(self._ptr, "short_name") @short_name.setter def short_name(self, name): W.buffer_set(self._ptr, "short_name", name) @property def name(self): return W.buffer_get_string(self._ptr, "name") @name.setter def name(self, name): W.buffer_set(self._ptr, "name", name) @property def number(self): """Get the buffer number, starts at 1.""" return int(W.buffer_get_integer(self._ptr, "number")) @number.setter def number(self, n): W.buffer_set(self._ptr, "number", str(n)) def find_lines(self, predicate, max_lines=None): lines = [] count = 0 for line in self.lines: if predicate(line): lines.append(line) count += 1 if max_lines is not None and count == max_lines: return lines return lines class RoomBuffer(object): def __init__(self, room, server_name, homeserver, prev_batch): self.room = room self.homeserver = homeserver self._backlog_pending = False self.prev_batch = prev_batch self.joined = True self.leave_event_id = None # type: Optional[str] self.members_fetched = False self.first_view = True self.first_backlog_request = True self.unhandled_users = [] # type: List[str] self.inactive_users = [] self.sent_messages_queue = dict() # type: Dict[UUID, OwnMessage] self.printed_before_ack_queue = list() # type: List[UUID] self.undecrypted_events = deque(maxlen=5000) self.typing_notice_time = None self._typing = False self.typing_enabled = True self.last_read_event = None self._read_markers_enabled = True self.server_name = server_name self.last_message = None buffer_name = "{}{}.{}".format(G.BUFFER_NAME_PREFIX, server_name, room.room_id) # This dict remembers the connection from a user_id to the name we # displayed in the buffer self.displayed_nicks = {} user = shorten_sender(self.room.own_user_id) self.weechat_buffer = WeechatChannelBuffer( buffer_name, server_name, user ) W.buffer_set( self.weechat_buffer._ptr, "localvar_set_domain", self.homeserver.hostname ) W.buffer_set( self.weechat_buffer._ptr, "localvar_set_room_id", room.room_id ) if room.canonical_alias: self.update_canonical_alias_localvar() @property def backlog_pending(self): return self._backlog_pending @backlog_pending.setter def backlog_pending(self, value): self._backlog_pending = value W.bar_item_update("buffer_modes") W.bar_item_update("matrix_modes") @property def warning_prefix(self): return G.CONFIG.look.encryption_warning_sign @property def typing(self): # type: () -> bool """Return our typing status.""" return self._typing @typing.setter def typing(self, value): self._typing = value if value: self.typing_notice_time = time.time() else: self.typing_notice_time = None @property def typing_notice_expired(self): # type: () -> bool """Check if the typing notice has expired. Returns true if a new typing notice should be sent. """ if not self.typing_notice_time: return True now = time.time() if (now - self.typing_notice_time) > (TYPING_NOTICE_TIMEOUT / 1000): return True return False @property def should_send_read_marker(self): # type () -> bool """Check if we need to send out a read receipt.""" if not self.read_markers_enabled: return False if not self.last_read_event: return True if self.last_read_event == self.last_event_id: return False return True @property def last_event_id(self): # type () -> str """Get the event id of the last shown matrix event.""" for line in self.weechat_buffer.lines: for tag in line.tags: if tag.startswith("matrix_id"): event_id = tag[10:] return event_id return "" @property def printed_event_ids(self): for line in self.weechat_buffer.lines: for tag in line.tags: if tag.startswith("matrix_id"): event_id = tag[10:] yield event_id @property def read_markers_enabled(self): # type: () -> bool """Check if read receipts are enabled for this room.""" return bool(int(W.string_eval_expression( G.CONFIG.network.read_markers_conditions, {}, {"markers_enabled": str(int(self._read_markers_enabled))}, {"type": "condition"} ))) @read_markers_enabled.setter def read_markers_enabled(self, value): self._read_markers_enabled = value def find_nick(self, user_id): # type: (str) -> str """Find a suitable nick from a user_id.""" if user_id in self.displayed_nicks: return self.displayed_nicks[user_id] return user_id def add_user(self, user_id, date, is_state, force_add=False): # User is already added don't add him again. if user_id in self.displayed_nicks: return try: user = self.room.users[user_id] except KeyError: # No user found, he must have left already in an event that is # yet to come, so do nothing return # Adding users to the nicklist is a O(1) + search time # operation (the nicks are added to a linked list sorted). # The search time is O(N * min(a,b)) where N is the number # of nicks already added and a/b are the length of # the strings that are compared at every iteration. # Because the search time get's increasingly longer we're # going to stop adding inactive users, they will be lazily added if # they become active. if is_state and not force_add and user.power_level <= 0: if (len(self.displayed_nicks) >= G.CONFIG.network.max_nicklist_users): self.inactive_users.append(user_id) return try: self.inactive_users.remove(user_id) except ValueError: pass short_name = shorten_sender(user.user_id) # TODO handle this special case for discord bridge users and # freenode bridge users better if (user.user_id.startswith("@_discord_") or user.user_id.startswith("@_slack_") or user.user_id.startswith("@_discordpuppet_") or user.user_id.startswith("@_slackpuppet_") or user.user_id.startswith("@whatsapp_") or user.user_id.startswith("@facebook_") or user.user_id.startswith("@telegram_") or user.user_id.startswith("@_telegram_") or user.user_id.startswith("@_xmpp_") or user.user_id.startswith("@irc_")): if user.display_name: short_name = user.display_name[0:50] elif user.user_id.startswith("@twilio_"): short_name = shorten_sender(user.user_id[7:]) elif user.user_id.startswith("@freenode_"): short_name = shorten_sender(user.user_id[9:]) elif user.user_id.startswith("@_ircnet_"): short_name = shorten_sender(user.user_id[8:]) elif user.user_id.startswith("@_oftc_"): short_name = shorten_sender(user.user_id[6:]) elif user.user_id.startswith("@gitter_"): short_name = shorten_sender(user.user_id[7:]) # TODO make this configurable if not short_name or short_name in self.displayed_nicks.values(): # Use the full user id, but don't include the @ nick = user_id[1:] else: nick = short_name buffer_user = RoomUser(nick, user_id, user.power_level, date) self.displayed_nicks[user_id] = nick if self.room.own_user_id == user_id: buffer_user.color = "weechat.color.chat_nick_self" user.nick_color = "weechat.color.chat_nick_self" self.weechat_buffer.join(buffer_user, date, not is_state) def handle_membership_events(self, event, is_state): date = server_ts_to_weechat(event.server_timestamp) if event.content["membership"] == "join": if (event.state_key not in self.displayed_nicks and event.state_key not in self.inactive_users): if len(self.room.users) > 100: self.unhandled_users.append(event.state_key) return self.add_user(event.state_key, date, is_state) else: # TODO print out profile changes return elif event.content["membership"] == "leave": if event.state_key in self.unhandled_users: self.unhandled_users.remove(event.state_key) return nick = self.find_nick(event.state_key) if event.sender == event.state_key: self.weechat_buffer.part(nick, date, not is_state) else: self.weechat_buffer.kick(nick, date, not is_state) if event.state_key in self.displayed_nicks: del self.displayed_nicks[event.state_key] # We left the room, remember the event id of our leave, if we # rejoin we get events that came before this event as well as # after our leave, this way we know where to continue if event.state_key == self.room.own_user_id: self.leave_event_id = event.event_id elif event.content["membership"] == "invite": if is_state: return self.weechat_buffer.invite(event.state_key, date) return self.update_buffer_name() def update_buffer_name(self): if self.room.is_named: if self.room.name and self.room.name != "#": room_name = self.room.name room_name = (room_name if room_name.startswith("#") else "#" + room_name) elif self.room.canonical_alias: room_name = self.room.canonical_alias self.update_canonical_alias_localvar() elif self.room.name == "#": room_name = "##" else: room_name = self.room.display_name if room_name is None: # Use placeholder room name room_name = 'Empty room (?)' self.weechat_buffer.short_name = room_name if G.CONFIG.human_buffer_names: buffer_name = "{}.{}".format(self.server_name, room_name) self.weechat_buffer.name = buffer_name def update_canonical_alias_localvar(self): W.buffer_set( self.weechat_buffer._ptr, "localvar_set_canonical_alias", self.room.canonical_alias ) def _redact_line(self, event): def predicate(event_id, line): def already_redacted(tags): if SCRIPT_NAME + "_redacted" in tags: return True return False event_tag = SCRIPT_NAME + "_id_{}".format(event_id) tags = line.tags if event_tag in tags and not already_redacted(tags): return True return False def redact_string(message): new_message = "" if G.CONFIG.look.redactions == RedactType.STRIKETHROUGH: plaintext_msg = W.string_remove_color(message, "") new_message = string_strikethrough(plaintext_msg) elif G.CONFIG.look.redactions == RedactType.NOTICE: new_message = message elif G.CONFIG.look.redactions == RedactType.DELETE: pass return new_message lines = self.weechat_buffer.find_lines( partial(predicate, event.redacts) ) # No line to redact, return early if not lines: return censor = self.find_nick(event.sender) redaction_msg = Render.redacted(censor, event.reason) line = lines[0] message = line.message tags = line.tags new_message = redact_string(message) message = " ".join(s for s in [new_message, redaction_msg] if s) tags.append("matrix_redacted") line.message = message line.tags = tags for line in lines[1:]: message = line.message tags = line.tags new_message = redact_string(message) if not new_message: new_message = redaction_msg elif G.CONFIG.look.redactions == RedactType.NOTICE: new_message += " {}".format(redaction_msg) tags.append("matrix_redacted") line.message = new_message line.tags = tags def _handle_topic(self, event, is_state): nick = self.find_nick(event.sender) self.weechat_buffer.change_topic( nick, event.topic, server_ts_to_weechat(event.server_timestamp), not is_state, ) @staticmethod def get_event_tags(event): # type: (Event) -> List[str] tags = [SCRIPT_NAME + "_id_{}".format(event.event_id)] if event.sender_key: tags.append(SCRIPT_NAME + "_senderkey_{}".format(event.sender_key)) if event.session_id: tags.append(SCRIPT_NAME + "_session_id_{}".format( event.session_id )) return tags def _handle_power_level(self, _): for user_id in self.room.power_levels.users: if user_id in self.displayed_nicks: nick = self.find_nick(user_id) user = self.weechat_buffer.users[nick] user.power_level = self.room.power_levels.get_user_level( user_id ) # There is no way to change the group of a user without # removing him from the nicklist self.weechat_buffer.remove_user_from_nicklist(user) self.weechat_buffer._add_user_to_nicklist(user) def handle_state_event(self, event): if isinstance(event, RoomMemberEvent): self.handle_membership_events(event, True) elif isinstance(event, RoomTopicEvent): self._handle_topic(event, True) elif isinstance(event, PowerLevelsEvent): self._handle_power_level(event) elif isinstance(event, (RoomNameEvent, RoomAliasEvent)): self.update_buffer_name() elif isinstance(event, RoomEncryptionEvent): pass def handle_own_message_in_timeline(self, event): """Check if our own message is already printed if not print it. This function is called for messages that contain a transaction id indicating that they were sent out using our own client. If we sent out a message but never got a valid server response (e.g. due to disconnects) this function prints them out using data from the next sync response""" uuid = UUID(event.transaction_id) message = self.sent_messages_queue.pop(uuid, None) # We already got a response to the room_send_message() API call and # handled the message, no need to print it out again if not message: return message.event_id = event.event_id if uuid in self.printed_before_ack_queue: self.replace_printed_line_by_uuid( event.transaction_id, message ) self.printed_before_ack_queue.remove(uuid) return if isinstance(message, OwnAction): self.self_action(message) elif isinstance(message, OwnMessage): self.self_message(message) return def print_room_message(self, event, extra_tags=None): extra_tags = extra_tags or [] nick = self.find_nick(event.sender) data = Render.message(event.body, event.formatted_body) extra_prefix = (self.warning_prefix if event.decrypted and not event.verified else "") date = server_ts_to_weechat(event.server_timestamp) self.weechat_buffer.message( nick, data, date, self.get_event_tags(event) + extra_tags, extra_prefix ) def print_room_emote(self, event, extra_tags=None): extra_tags = extra_tags or [] nick = self.find_nick(event.sender) date = server_ts_to_weechat(event.server_timestamp) extra_prefix = (self.warning_prefix if event.decrypted and not event.verified else "") self.weechat_buffer.action( nick, event.body, date, self.get_event_tags(event) + extra_tags, extra_prefix ) def print_room_notice(self, event, extra_tags=None): extra_tags = extra_tags or [] nick = self.find_nick(event.sender) date = server_ts_to_weechat(event.server_timestamp) extra_prefix = (self.warning_prefix if event.decrypted and not event.verified else "") self.weechat_buffer.notice( nick, event.body, date, self.get_event_tags(event) + extra_tags, extra_prefix ) def print_room_media(self, event, extra_tags=None): extra_tags = extra_tags or [] nick = self.find_nick(event.sender) date = server_ts_to_weechat(event.server_timestamp) if isinstance(event, RoomMessageMedia): data = Render.media(event.url, event.body, self.homeserver.geturl()) else: data = Render.encrypted_media( event.url, event.body, event.key["k"], event.hashes["sha256"], event.iv, self.homeserver.geturl() ) extra_prefix = (self.warning_prefix if event.decrypted and not event.verified else "") self.weechat_buffer.message( nick, data, date, self.get_event_tags(event) + extra_tags, extra_prefix ) def print_unknown(self, event, extra_tags=None): extra_tags = extra_tags or [] nick = self.find_nick(event.sender) date = server_ts_to_weechat(event.server_timestamp) data = Render.unknown(event.type, event.content) extra_prefix = (self.warning_prefix if event.decrypted and not event.verified else "") self.weechat_buffer.message( nick, data, date, self.get_event_tags(event) + extra_tags, extra_prefix ) def print_redacted(self, event, extra_tags=None): extra_tags = extra_tags or [] nick = self.find_nick(event.sender) date = server_ts_to_weechat(event.server_timestamp) tags = self.get_event_tags(event) tags.append(SCRIPT_NAME + "_redacted") tags += extra_tags censor = self.find_nick(event.redacter) data = Render.redacted(censor, event.reason) self.weechat_buffer.message(nick, data, date, tags) def print_room_encryption(self, event, extra_tags=None): nick = self.find_nick(event.sender) data = Render.room_encryption(nick) # TODO this should also have tags self.weechat_buffer.info(data) def print_megolm(self, event, extra_tags=None): extra_tags = extra_tags or [] nick = self.find_nick(event.sender) date = server_ts_to_weechat(event.server_timestamp) data = Render.megolm() session_id_tag = SCRIPT_NAME + "_sessionid_" + event.session_id self.weechat_buffer.message( nick, data, date, self.get_event_tags(event) + [session_id_tag] + extra_tags ) self.undecrypted_events.append(event) def print_bad_event(self, event, extra_tags=None): extra_tags = extra_tags or [] nick = self.find_nick(event.sender) date = server_ts_to_weechat(event.server_timestamp) data = Render.bad(event) extra_prefix = self.warning_prefix self.weechat_buffer.message( nick, data, date, self.get_event_tags(event) + extra_tags, extra_prefix ) def handle_room_messages(self, event, extra_tags=None): if isinstance(event, RoomMessageEmote): self.print_room_emote(event, extra_tags) elif isinstance(event, RoomMessageText): self.print_room_message(event, extra_tags) elif isinstance(event, RoomMessageNotice): self.print_room_notice(event, extra_tags) elif isinstance(event, RoomMessageMedia): self.print_room_media(event, extra_tags) elif isinstance(event, RoomEncryptedMedia): self.print_room_media(event, extra_tags) elif isinstance(event, RoomMessageUnknown): self.print_unknown(event, extra_tags) elif isinstance(event, RoomEncryptionEvent): self.print_room_encryption(event, extra_tags) elif isinstance(event, MegolmEvent): self.print_megolm(event, extra_tags) def force_load_member(self, event): if (event.sender not in self.displayed_nicks and event.sender in self.room.users): try: self.unhandled_users.remove(event.sender) except ValueError: pass self.add_user(event.sender, 0, True, True) def handle_timeline_event(self, event, extra_tags=None): # TODO this should be done for every messagetype that gets printed in # the buffer if isinstance(event, (RoomMessage, MegolmEvent)): self.force_load_member(event) if event.transaction_id: self.handle_own_message_in_timeline(event) return if isinstance(event, RoomMemberEvent): self.handle_membership_events(event, False) elif isinstance(event, (RoomNameEvent, RoomAliasEvent)): self.update_buffer_name() elif isinstance(event, RoomTopicEvent): self._handle_topic(event, False) # Emotes are a subclass of RoomMessageText, so put them before the text # ones elif isinstance(event, RoomMessageEmote): self.print_room_emote(event, extra_tags) elif isinstance(event, RoomMessageText): self.print_room_message(event, extra_tags) elif isinstance(event, RoomMessageNotice): self.print_room_notice(event, extra_tags) elif isinstance(event, RoomMessageMedia): self.print_room_media(event, extra_tags) elif isinstance(event, RoomEncryptedMedia): self.print_room_media(event, extra_tags) elif isinstance(event, RoomMessageUnknown): self.print_unknown(event, extra_tags) elif isinstance(event, RedactionEvent): self._redact_line(event) elif isinstance(event, RedactedEvent): self.print_redacted(event, extra_tags) elif isinstance(event, RoomEncryptionEvent): self.print_room_encryption(event, extra_tags) elif isinstance(event, PowerLevelsEvent): # TODO we should print out a message for this event self._handle_power_level(event) elif isinstance(event, MegolmEvent): self.print_megolm(event, extra_tags) elif isinstance(event, UnknownEvent): pass elif isinstance(event, BadEvent): self.print_bad_event(event, extra_tags) elif isinstance(event, UnknownBadEvent): self.error("Unknown bad event: {}".format( pprint.pformat(event.source) )) else: W.prnt( "", "Unhandled event of type {}.".format(type(event).__name__) ) def self_message(self, message): # type: (OwnMessage) -> None nick = self.find_nick(self.room.own_user_id) data = message.formatted_message.to_weechat() if message.event_id: tags = [SCRIPT_NAME + "_id_{}".format(message.event_id)] else: tags = [SCRIPT_NAME + "_uuid_{}".format(message.uuid)] date = message.age self.weechat_buffer.self_message(nick, data, date, tags) def self_action(self, message): # type: (OwnMessage) -> None nick = self.find_nick(self.room.own_user_id) date = message.age if message.event_id: tags = [SCRIPT_NAME + "_id_{}".format(message.event_id)] else: tags = [SCRIPT_NAME + "_uuid_{}".format(message.uuid)] self.weechat_buffer.self_action( nick, message.formatted_message.to_weechat(), date, tags ) @staticmethod def _find_by_uuid_predicate(uuid, line): uuid_tag = SCRIPT_NAME + "_uuid_{}".format(uuid) tags = line.tags if uuid_tag in tags: return True return False def mark_message_as_unsent(self, uuid, _): """Append to already printed lines that are greyed out an error message""" lines = self.weechat_buffer.find_lines( partial(self._find_by_uuid_predicate, uuid) ) last_line = lines[-1] message = last_line.message message += (" {del_color}<{ncolor}{error_color}Error sending " "message{del_color}>{ncolor}").format( del_color=W.color("chat_delimiters"), ncolor=W.color("reset"), error_color=W.color(color_pair( G.CONFIG.color.error_message_fg, G.CONFIG.color.error_message_bg))) last_line.message = message def replace_printed_line_by_uuid(self, uuid, new_message): """Replace already printed lines that are greyed out with real ones.""" if isinstance(new_message, OwnAction): displayed_nick = self.displayed_nicks[self.room.own_user_id] user = self.weechat_buffer._get_user(displayed_nick) data = self.weechat_buffer._format_action( user, new_message.formatted_message.to_weechat() ) new_lines = data.split("\n") else: new_lines = new_message.formatted_message.to_weechat().split("\n") line_count = len(new_lines) lines = self.weechat_buffer.find_lines( partial(self._find_by_uuid_predicate, uuid), line_count ) for i, line in enumerate(lines): line.message = new_lines[i] tags = line.tags new_tags = [ tag for tag in tags if not tag.startswith(SCRIPT_NAME + "_uuid_") ] new_tags.append(SCRIPT_NAME + "_id_" + new_message.event_id) line.tags = new_tags def replace_undecrypted_line(self, event): """Find an undecrypted message in the buffer and replace it with the now decrypted event.""" # TODO different messages need different formatting # To implement this, refactor out the different formatting code # snippets to a Formatter class and reuse them here. if not isinstance(event, RoomMessageText): return def predicate(event_id, line): event_tag = SCRIPT_NAME + "_id_{}".format(event_id) if event_tag in line.tags: return True return False lines = self.weechat_buffer.find_lines( partial(predicate, event.event_id) ) if not lines: return formatted = None if event.formatted_body: formatted = Formatted.from_html(event.formatted_body) data = formatted.to_weechat() if formatted else event.body # TODO this isn't right if the data has multiple lines, that is # everything is printed on a single line and newlines are shown as a # space. # Weechat should support deleting lines and printing new ones at an # arbitrary position. # To implement this without weechat support either only handle single # line messages or edit the first line in place, print new ones at the # bottom and sort the buffer lines. lines[0].message = data def old_message(self, event): tags = list(self.weechat_buffer.tags["old_message"]) # TODO events that change the room state (topics, membership changes, # etc...) should be printed out as well, but for this to work without # messing up the room state the state change will need to be separated # from the print logic. if isinstance(event, RoomMessage): self.force_load_member(event) self.handle_room_messages(event, tags) elif isinstance(event, MegolmEvent): self.print_megolm(event, tags) elif isinstance(event, RedactedEvent): self.print_redacted(event, tags) elif isinstance(event, BadEvent): self.print_bad_event(event, tags) def sort_messages(self): class LineCopy(object): def __init__( self, date, date_printed, tags, prefix, message, highlight ): self.date = date self.date_printed = date_printed self.tags = tags self.prefix = prefix self.message = message self.highlight = highlight @classmethod def from_line(cls, line): return cls( line.date, line.date_printed, line.tags, line.prefix, line.message, line.highlight, ) lines = [ LineCopy.from_line(line) for line in self.weechat_buffer.lines ] sorted_lines = sorted(lines, key=lambda line: line.date, reverse=True) for line_number, line in enumerate(self.weechat_buffer.lines): new = sorted_lines[line_number] line.update( new.date, new.date_printed, new.tags, new.prefix, new.message ) def handle_backlog(self, response): self.prev_batch = response.end for event in response.chunk: # The first backlog request seems to have a race condition going on # where we receive a message in a sync response, get a prev_batch, # yet when we request older messages with the prev_batch the same # message might appear in the room messages response. This only # seems to happen if the message is relatively recently sent. # Because of this we check if our first backlog request contains # some already printed events, if so; skip printing them. if (self.first_backlog_request and event.event_id in self.printed_event_ids): continue self.old_message(event) self.sort_messages() self.first_backlog_request = False self.backlog_pending = False def handle_joined_room(self, info): for event in info.state: self.handle_state_event(event) timeline_events = None # This is a rejoin, skip already handled events if not self.joined: leave_index = None for i, event in enumerate(info.timeline.events): if event.event_id == self.leave_event_id: leave_index = i break if leave_index: timeline_events = info.timeline.events[leave_index + 1:] # Handle our leave as a state event since we're not in the # nicklist anymore but we're already printed out our leave self.handle_state_event(info.timeline.events[leave_index]) else: timeline_events = info.timeline.events # mark that we are now joined self.joined = True else: timeline_events = info.timeline.events for event in timeline_events: self.handle_timeline_event(event) for event in info.account_data: if isinstance(event, FullyReadEvent): if event.event_id == self.last_event_id: current_buffer = W.buffer_search("", "") if self.weechat_buffer._ptr == current_buffer: continue W.buffer_set(self.weechat_buffer._ptr, "unread", "") W.buffer_set(self.weechat_buffer._ptr, "hotlist", "-1") # We didn't handle all joined users, the room display name might still # be outdated because of that, update it now. if self.unhandled_users: self.update_buffer_name() def handle_left_room(self, info): self.joined = False for event in info.state: self.handle_state_event(event) for event in info.timeline.events: self.handle_timeline_event(event) def error(self, string): # type: (str) -> None self.weechat_buffer.error(string) weechat-matrix-0.3.0/matrix/colors.py000066400000000000000000001215061407004440000176110ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Copyright © 2008 Nicholas Marriott # Copyright © 2016 Avi Halachmi # Copyright © 2018, 2019 Damir Jelić # Copyright © 2018, 2019 Denis Kasak # # Permission to use, copy, modify, and/or distribute this software for # any purpose with or without fee is hereby granted, provided that the # above copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. from __future__ import unicode_literals import html import re import textwrap # pylint: disable=redefined-builtin from builtins import str from collections import namedtuple from typing import Dict, List, Optional, Union import webcolors from pygments import highlight from pygments.formatter import Formatter, get_style_by_name from pygments.lexers import get_lexer_by_name from pygments.util import ClassNotFound from . import globals as G from .globals import W from .utils import (string_strikethrough, string_color_and_reset, color_pair, text_block, colored_text_block) try: from HTMLParser import HTMLParser except ImportError: from html.parser import HTMLParser class FormattedString: __slots__ = ("text", "attributes") def __init__(self, text, attributes): self.attributes = DEFAULT_ATTRIBUTES.copy() self.attributes.update(attributes) self.text = text class Formatted(object): def __init__(self, substrings): # type: (List[FormattedString]) -> None self.substrings = substrings def textwrapper(self, width, colors): return textwrap.TextWrapper( width=width, initial_indent="{}> ".format(W.color(colors)), subsequent_indent="{}> ".format(W.color(colors)), ) def is_formatted(self): # type: (Formatted) -> bool for string in self.substrings: if string.attributes != DEFAULT_ATTRIBUTES: return True return False # TODO reverse video @classmethod def from_input_line(cls, line): # type: (str) -> Formatted """Parses the weechat input line and produces formatted strings that can be later converted to HTML or to a string for weechat's print functions """ text = "" # type: str substrings = [] # type: List[FormattedString] attributes = DEFAULT_ATTRIBUTES.copy() # If this is false, only IRC formatting characters will be parsed. do_markdown = G.CONFIG.look.markdown_input # Disallow backticks in URLs so that code blocks are unaffected by the # URL handling url_regex = r"\b[a-z]+://[^\s`]+" # Escaped things are not markdown delimiters, so substitute them away # when (quickly) looking for the last delimiters in the line. # Additionally, URLs are ignored for the purposes of markdown # delimiters. # Note that the replacement needs to be the same length as the original # for the indices to be correct. escaped_masked = re.sub( r"\\[\\*_`]|(?:" + url_regex + ")", lambda m: "a" * len(m.group(0)), line ) def last_match_index(regex, offset_in_match): matches = list(re.finditer(regex, escaped_masked)) return matches[-1].span()[0] + offset_in_match if matches else -1 # 'needs_word': whether the wrapper must surround words, for example # '*italic*' and not '* not-italic *'. # 'validate': whether it can occur within the current attributes wrappers = { "**": { "key": "bold", "last_index": last_match_index(r"\S\*\*", 1), "needs_word": True, "validate": lambda attrs: not attrs["code"], }, "*": { "key": "italic", "last_index": last_match_index(r"\S\*($|[^*])", 1), "needs_word": True, "validate": lambda attrs: not attrs["code"], }, "_": { "key": "italic", "last_index": last_match_index(r"\S_", 1), "needs_word": True, "validate": lambda attrs: not attrs["code"], }, "`": { "key": "code", "last_index": last_match_index(r"`", 0), "needs_word": False, "validate": lambda attrs: True, } } wrapper_init_chars = set(k[0] for k in wrappers.keys()) wrapper_max_len = max(len(k) for k in wrappers.keys()) irc_toggles = { "\x02": "bold", "\x1D": "italic", "\x1F": "underline", } # Characters that consume a prefixed backslash escapable_chars = wrapper_init_chars.copy() escapable_chars.add("\\") # Collect URL spans url_spans = [m.span() for m in re.finditer(url_regex, line)] url_spans.reverse() # we'll be popping from the end # Whether we are currently in a URL in_url = False i = 0 while i < len(line): # Update the 'in_url' flag. The first condition is not a while loop # because URLs must contain '://', ensuring that we will not skip 2 # URLs in one iteration. if url_spans and i >= url_spans[-1][1]: in_url = False url_spans.pop() if url_spans and i >= url_spans[-1][0]: in_url = True # Markdown escape if do_markdown and \ i + 1 < len(line) and line[i] == "\\" \ and (line[i + 1] in escapable_chars if not attributes["code"] else line[i + 1] == "`") \ and not in_url: text += line[i + 1] i = i + 2 # IRC bold/italic/underline elif line[i] in irc_toggles and not attributes["code"]: if text: substrings.append(FormattedString(text, attributes.copy())) text = "" key = irc_toggles[line[i]] attributes[key] = not attributes[key] i = i + 1 # IRC reset elif line[i] == "\x0F" and not attributes["code"]: if text: substrings.append(FormattedString(text, attributes.copy())) text = "" # Reset all the attributes attributes = DEFAULT_ATTRIBUTES.copy() i = i + 1 # IRC color elif line[i] == "\x03" and not attributes["code"]: if text: substrings.append(FormattedString(text, attributes.copy())) text = "" i = i + 1 # check if it's a valid color, add it to the attributes if line[i].isdigit(): color_string = line[i] i = i + 1 if line[i].isdigit(): if color_string == "0": color_string = line[i] else: color_string = color_string + line[i] i = i + 1 attributes["fgcolor"] = color_line_to_weechat(color_string) else: attributes["fgcolor"] = None # check if we have a background color if line[i] == "," and line[i + 1].isdigit(): color_string = line[i + 1] i = i + 2 if line[i].isdigit(): if color_string == "0": color_string = line[i] else: color_string = color_string + line[i] i = i + 1 attributes["bgcolor"] = color_line_to_weechat(color_string) else: attributes["bgcolor"] = None # Markdown wrapper (emphasis/bold/code) elif do_markdown and line[i] in wrapper_init_chars and not in_url: for l in range(wrapper_max_len, 0, -1): if i + l <= len(line) and line[i : i + l] in wrappers: descriptor = wrappers[line[i : i + l]] if not descriptor["validate"](attributes): continue if attributes[descriptor["key"]]: # needs_word wrappers can only be turned off if # preceded by non-whitespace if (i >= 1 and not line[i - 1].isspace()) \ or not descriptor["needs_word"]: if text: # strip leading and trailing spaces and # compress consecutive spaces in inline # code blocks if descriptor["key"] == "code": text = re.sub(r"\s+", " ", text.strip()) substrings.append( FormattedString(text, attributes.copy())) text = "" attributes[descriptor["key"]] = False i = i + l else: text = text + line[i : i + l] i = i + l # Must have a chance of closing this, and needs_word # wrappers must be followed by non-whitespace elif descriptor["last_index"] >= i + l and \ (not line[i + l].isspace() or \ not descriptor["needs_word"]): if text: substrings.append( FormattedString(text, attributes.copy())) text = "" attributes[descriptor["key"]] = True i = i + l else: text = text + line[i : i + l] i = i + l break else: # No wrapper matched here (NOTE: cannot happen since all # wrapper prefixes are also wrappers, but for completeness' # sake) text = text + line[i] i = i + 1 # Normal text else: text = text + line[i] i = i + 1 if text: substrings.append(FormattedString(text, attributes)) return cls(substrings) @classmethod def from_html(cls, html): # type: (str) -> Formatted parser = MatrixHtmlParser() parser.feed(html) return cls(parser.get_substrings()) def to_html(self): def add_attribute(string, name, value): if name == "bold" and value: return "{bold_on}{text}{bold_off}".format( bold_on="", text=string, bold_off="" ) if name == "italic" and value: return "{italic_on}{text}{italic_off}".format( italic_on="", text=string, italic_off="" ) if name == "underline" and value: return "{underline_on}{text}{underline_off}".format( underline_on="", text=string, underline_off="" ) if name == "strikethrough" and value: return "{strike_on}{text}{strike_off}".format( strike_on="", text=string, strike_off="" ) if name == "quote" and value: return "{quote_on}{text}{quote_off}".format( quote_on="
", text=string, quote_off="
", ) if name == "code" and value: return "{code_on}{text}{code_off}".format( code_on="", text=string, code_off="" ) return string def add_color(string, fgcolor, bgcolor): fgcolor_string = "" bgcolor_string = "" if fgcolor: fgcolor_string = " data-mx-color={}".format( color_weechat_to_html(fgcolor) ) if bgcolor: bgcolor_string = " data-mx-bg-color={}".format( color_weechat_to_html(bgcolor) ) return "{color_on}{text}{color_off}".format( color_on="".format( fg=fgcolor_string, bg=bgcolor_string ), text=string, color_off="", ) def format_string(formatted_string): text = formatted_string.text attributes = formatted_string.attributes.copy() # Escape HTML tag characters text = text.replace("&", "&") \ .replace("<", "<") \ .replace(">", ">") if attributes["code"]: if attributes["preformatted"]: # XXX: This can't really happen since there's no way of # creating preformatted code blocks in weechat (because # there is not multiline input), but I'm creating this # branch as a note that it should be handled once we do # implement them. pass else: text = add_attribute(text, "code", True) attributes.pop("code") if attributes["fgcolor"] or attributes["bgcolor"]: text = add_color( text, attributes["fgcolor"], attributes["bgcolor"] ) if attributes["fgcolor"]: attributes.pop("fgcolor") if attributes["bgcolor"]: attributes.pop("bgcolor") for key, value in attributes.items(): text = add_attribute(text, key, value) return text html_string = map(format_string, self.substrings) return "".join(html_string) # TODO do we want at least some formatting using unicode # (strikethrough, quotes)? def to_plain(self): # type: () -> str def strip_atribute(string, _, __): return string def format_string(formatted_string): text = formatted_string.text attributes = formatted_string.attributes for key, value in attributes.items(): text = strip_atribute(text, key, value) return text plain_string = map(format_string, self.substrings) return "".join(plain_string) def to_weechat(self): def add_attribute(string, name, value, attributes): if not value: return string elif name == "bold": return "{bold_on}{text}{bold_off}".format( bold_on=W.color("bold"), text=string, bold_off=W.color("-bold"), ) elif name == "italic": return "{italic_on}{text}{italic_off}".format( italic_on=W.color("italic"), text=string, italic_off=W.color("-italic"), ) elif name == "underline": return "{underline_on}{text}{underline_off}".format( underline_on=W.color("underline"), text=string, underline_off=W.color("-underline"), ) elif name == "strikethrough": return string_strikethrough(string) elif name == "quote": quote_pair = color_pair(G.CONFIG.color.quote_fg, G.CONFIG.color.quote_bg) # Remove leading and trailing newlines; Riot sends an extra # quoted "\n" when a user quotes a message. string = string.strip("\n") if len(string) == 0: return string if G.CONFIG.look.quote_wrap >= 0: wrapper = self.textwrapper(G.CONFIG.look.quote_wrap, quote_pair) return wrapper.fill(W.string_remove_color(string, "")) else: # Don't wrap, just add quote markers to all lines return "{color_on}{text}{color_off}".format( color_on=W.color(quote_pair), text="> " + W.string_remove_color(string.replace("\n", "\n> "), ""), color_off=W.color("resetcolor") ) elif name == "code": code_color_pair = color_pair( G.CONFIG.color.untagged_code_fg, G.CONFIG.color.untagged_code_bg ) margin = G.CONFIG.look.code_block_margin if attributes["preformatted"]: # code block try: lexer = get_lexer_by_name(value) except ClassNotFound: if G.CONFIG.look.code_blocks: return colored_text_block( string, margin=margin, color_pair=code_color_pair) else: return string_color_and_reset(string, code_color_pair) try: style = get_style_by_name(G.CONFIG.look.pygments_style) except ClassNotFound: style = "native" if G.CONFIG.look.code_blocks: code_block = text_block(string, margin=margin) else: code_block = string # highlight adds a newline to the end of the string, remove # it from the output highlighted_code = highlight( code_block, lexer, WeechatFormatter(style=style) ).rstrip() return highlighted_code else: return string_color_and_reset(string, code_color_pair) elif name == "fgcolor": return "{color_on}{text}{color_off}".format( color_on=W.color(value), text=string, color_off=W.color("resetcolor"), ) elif name == "bgcolor": return "{color_on}{text}{color_off}".format( color_on=W.color("," + value), text=string, color_off=W.color("resetcolor"), ) else: return string def format_string(formatted_string): text = formatted_string.text attributes = formatted_string.attributes # We need to handle strikethrough first, since doing # a strikethrough followed by other attributes succeeds in the # terminal, but doing it the other way around results in garbage. if "strikethrough" in attributes: text = add_attribute( text, "strikethrough", attributes["strikethrough"], attributes ) attributes.pop("strikethrough") def indent(text, prefix): return prefix + text.replace("\n", "\n{}".format(prefix)) for key, value in attributes.items(): if not value: continue # Don't use textwrap to quote the code if key == "quote" and attributes["code"]: continue # Reflow inline code blocks if key == "code" and not attributes["preformatted"]: text = text.strip().replace('\n', ' ') text = add_attribute(text, key, value, attributes) # If we're quoted code add quotation marks now. if key == "code" and attributes["quote"]: fg = G.CONFIG.color.quote_fg bg = G.CONFIG.color.quote_bg text = indent( text, string_color_and_reset(">", color_pair(fg, bg)) + " ", ) # If we're code don't remove multiple newlines blindly if attributes["code"]: return text return re.sub(r"\n+", "\n", text) weechat_strings = map(format_string, self.substrings) # Remove duplicate \n elements from the list strings = [] for string in weechat_strings: if len(strings) == 0 or string != "\n" or string != strings[-1]: strings.append(string) return "".join(strings).strip() DEFAULT_ATTRIBUTES = { "bold": False, "italic": False, "underline": False, "strikethrough": False, "preformatted": False, "quote": False, "code": None, "fgcolor": None, "bgcolor": None, } # type: Dict[str, Union[bool, Optional[str]]] class MatrixHtmlParser(HTMLParser): # TODO bullets def __init__(self): HTMLParser.__init__(self) self.text = "" # type: str self.substrings = [] # type: List[FormattedString] self.attributes = DEFAULT_ATTRIBUTES.copy() def unescape(self, text): """Shim to unescape HTML in both Python 2 and 3. The instance method was deprecated in Python 3 and html.unescape doesn't exist in Python 2 so this is needed. """ try: return html.unescape(text) except AttributeError: return HTMLParser.unescape(self, text) def add_substring(self, text, attrs): fmt_string = FormattedString(text, attrs) self.substrings.append(fmt_string) def _toggle_attribute(self, attribute): if self.text: self.add_substring(self.text, self.attributes.copy()) self.text = "" self.attributes[attribute] = not self.attributes[attribute] def handle_starttag(self, tag, attrs): if tag == "strong": self._toggle_attribute("bold") elif tag == "em": self._toggle_attribute("italic") elif tag == "u": self._toggle_attribute("underline") elif tag == "del": self._toggle_attribute("strikethrough") elif tag == "blockquote": self._toggle_attribute("quote") elif tag == "pre": self._toggle_attribute("preformatted") elif tag == "code": lang = None for key, value in attrs: if key == "class": if value.startswith("language-"): lang = value.split("-", 1)[1] lang = lang or "unknown" if self.text: self.add_substring(self.text, self.attributes.copy()) self.text = "" self.attributes["code"] = lang elif tag == "p": if self.text: self.add_substring(self.text, self.attributes.copy()) self.text = "\n" self.add_substring(self.text, DEFAULT_ATTRIBUTES.copy()) self.text = "" elif tag == "br": if self.text: self.add_substring(self.text, self.attributes.copy()) self.text = "\n" self.add_substring(self.text, DEFAULT_ATTRIBUTES.copy()) self.text = "" elif tag == "font": for key, value in attrs: if key in ["data-mx-color", "color"]: color = color_html_to_weechat(value) if not color: continue if self.text: self.add_substring(self.text, self.attributes.copy()) self.text = "" self.attributes["fgcolor"] = color elif key in ["data-mx-bg-color"]: color = color_html_to_weechat(value) if not color: continue if self.text: self.add_substring(self.text, self.attributes.copy()) self.text = "" self.attributes["bgcolor"] = color else: pass def handle_endtag(self, tag): if tag == "strong": self._toggle_attribute("bold") elif tag == "em": self._toggle_attribute("italic") elif tag == "u": self._toggle_attribute("underline") elif tag == "del": self._toggle_attribute("strikethrough") elif tag == "pre": self._toggle_attribute("preformatted") elif tag == "code": if self.text: self.add_substring(self.text, self.attributes.copy()) self.text = "" self.attributes["code"] = None elif tag == "blockquote": self._toggle_attribute("quote") self.text = "\n" self.add_substring(self.text, DEFAULT_ATTRIBUTES.copy()) self.text = "" elif tag == "font": if self.text: self.add_substring(self.text, self.attributes.copy()) self.text = "" self.attributes["fgcolor"] = None else: pass def handle_data(self, data): self.text += data def handle_entityref(self, name): self.text += self.unescape("&{};".format(name)) def handle_charref(self, name): self.text += self.unescape("&#{};".format(name)) def get_substrings(self): if self.text: self.add_substring(self.text, self.attributes.copy()) return self.substrings def color_line_to_weechat(color_string): # type: (str) -> str line_colors = { "0": "white", "1": "black", "2": "blue", "3": "green", "4": "lightred", "5": "red", "6": "magenta", "7": "brown", "8": "yellow", "9": "lightgreen", "10": "cyan", "11": "lightcyan", "12": "lightblue", "13": "lightmagenta", "14": "darkgray", "15": "gray", "16": "52", "17": "94", "18": "100", "19": "58", "20": "22", "21": "29", "22": "23", "23": "24", "24": "17", "25": "54", "26": "53", "27": "89", "28": "88", "29": "130", "30": "142", "31": "64", "32": "28", "33": "35", "34": "30", "35": "25", "36": "18", "37": "91", "38": "90", "39": "125", "40": "124", "41": "166", "42": "184", "43": "106", "44": "34", "45": "49", "46": "37", "47": "33", "48": "19", "49": "129", "50": "127", "51": "161", "52": "196", "53": "208", "54": "226", "55": "154", "56": "46", "57": "86", "58": "51", "59": "75", "60": "21", "61": "171", "62": "201", "63": "198", "64": "203", "65": "215", "66": "227", "67": "191", "68": "83", "69": "122", "70": "87", "71": "111", "72": "63", "73": "177", "74": "207", "75": "205", "76": "217", "77": "223", "78": "229", "79": "193", "80": "157", "81": "158", "82": "159", "83": "153", "84": "147", "85": "183", "86": "219", "87": "212", "88": "16", "89": "233", "90": "235", "91": "237", "92": "239", "93": "241", "94": "244", "95": "247", "96": "250", "97": "254", "98": "231", "99": "default", } assert color_string in line_colors return line_colors[color_string] # The functions color_dist_sq(), color_to_6cube(), and color_find_rgb # are python ports of the same named functions from the tmux # source, they are under the copyright of Nicholas Marriott, and Avi Halachmi # under the ISC license. # More info: https://github.com/tmux/tmux/blob/master/colour.c def color_dist_sq(R, G, B, r, g, b): # pylint: disable=invalid-name,too-many-arguments # type: (int, int, int, int, int, int) -> int return (R - r) * (R - r) + (G - g) * (G - g) + (B - b) * (B - b) def color_to_6cube(v): # pylint: disable=invalid-name # type: (int) -> int if v < 48: return 0 if v < 114: return 1 return (v - 35) // 40 def color_find_rgb(r, g, b): # type: (int, int, int) -> int """Convert an RGB triplet to the xterm(1) 256 color palette. xterm provides a 6x6x6 color cube (16 - 231) and 24 greys (232 - 255). We map our RGB color to the closest in the cube, also work out the closest grey, and use the nearest of the two. Note that the xterm has much lower resolution for darker colors (they are not evenly spread out), so our 6 levels are not evenly spread: 0x0, 0x5f (95), 0x87 (135), 0xaf (175), 0xd7 (215) and 0xff (255). Greys are more evenly spread (8, 18, 28 ... 238). """ # pylint: disable=invalid-name q2c = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff] # Map RGB to 6x6x6 cube. qr = color_to_6cube(r) qg = color_to_6cube(g) qb = color_to_6cube(b) cr = q2c[qr] cg = q2c[qg] cb = q2c[qb] # If we have hit the color exactly, return early. if cr == r and cg == g and cb == b: return 16 + (36 * qr) + (6 * qg) + qb # Work out the closest grey (average of RGB). grey_avg = (r + g + b) // 3 if grey_avg > 238: grey_idx = 23 else: grey_idx = (grey_avg - 3) // 10 grey = 8 + (10 * grey_idx) # Is grey or 6x6x6 color closest? d = color_dist_sq(cr, cg, cb, r, g, b) if color_dist_sq(grey, grey, grey, r, g, b) < d: idx = 232 + grey_idx else: idx = 16 + (36 * qr) + (6 * qg) + qb return idx def color_html_to_weechat(color): # type: (str) -> str # yapf: disable weechat_basic_colors = { (0, 0, 0): "black", # 0 (128, 0, 0): "red", # 1 (0, 128, 0): "green", # 2 (128, 128, 0): "brown", # 3 (0, 0, 128): "blue", # 4 (128, 0, 128): "magenta", # 5 (0, 128, 128): "cyan", # 6 (192, 192, 192): "default", # 7 (128, 128, 128): "gray", # 8 (255, 0, 0): "lightred", # 9 (0, 255, 0): "lightgreen", # 10 (255, 255, 0): "yellow", # 11 (0, 0, 255): "lightblue", # 12 (255, 0, 255): "lightmagenta", # 13 (0, 255, 255): "lightcyan", # 14 (255, 255, 255): "white", # 15 } # yapf: enable try: rgb_color = webcolors.html5_parse_legacy_color(color) except ValueError: return "" if rgb_color in weechat_basic_colors: return weechat_basic_colors[rgb_color] return str(color_find_rgb(*rgb_color)) def color_weechat_to_html(color): # type: (str) -> str # yapf: disable weechat_basic_colors = { "black": "0", "red": "1", "green": "2", "brown": "3", "blue": "4", "magenta": "5", "cyan": "6", "default": "7", "gray": "8", "lightred": "9", "lightgreen": "10", "yellow": "11", "lightblue": "12", "lightmagenta": "13", "lightcyan": "14", "white": "15", } hex_colors = { "0": "#000000", "1": "#800000", "2": "#008000", "3": "#808000", "4": "#000080", "5": "#800080", "6": "#008080", "7": "#c0c0c0", "8": "#808080", "9": "#ff0000", "10": "#00ff00", "11": "#ffff00", "12": "#0000ff", "13": "#ff00ff", "14": "#00ffff", "15": "#ffffff", "16": "#000000", "17": "#00005f", "18": "#000087", "19": "#0000af", "20": "#0000d7", "21": "#0000ff", "22": "#005f00", "23": "#005f5f", "24": "#005f87", "25": "#005faf", "26": "#005fd7", "27": "#005fff", "28": "#008700", "29": "#00875f", "30": "#008787", "31": "#0087af", "32": "#0087d7", "33": "#0087ff", "34": "#00af00", "35": "#00af5f", "36": "#00af87", "37": "#00afaf", "38": "#00afd7", "39": "#00afff", "40": "#00d700", "41": "#00d75f", "42": "#00d787", "43": "#00d7af", "44": "#00d7d7", "45": "#00d7ff", "46": "#00ff00", "47": "#00ff5f", "48": "#00ff87", "49": "#00ffaf", "50": "#00ffd7", "51": "#00ffff", "52": "#5f0000", "53": "#5f005f", "54": "#5f0087", "55": "#5f00af", "56": "#5f00d7", "57": "#5f00ff", "58": "#5f5f00", "59": "#5f5f5f", "60": "#5f5f87", "61": "#5f5faf", "62": "#5f5fd7", "63": "#5f5fff", "64": "#5f8700", "65": "#5f875f", "66": "#5f8787", "67": "#5f87af", "68": "#5f87d7", "69": "#5f87ff", "70": "#5faf00", "71": "#5faf5f", "72": "#5faf87", "73": "#5fafaf", "74": "#5fafd7", "75": "#5fafff", "76": "#5fd700", "77": "#5fd75f", "78": "#5fd787", "79": "#5fd7af", "80": "#5fd7d7", "81": "#5fd7ff", "82": "#5fff00", "83": "#5fff5f", "84": "#5fff87", "85": "#5fffaf", "86": "#5fffd7", "87": "#5fffff", "88": "#870000", "89": "#87005f", "90": "#870087", "91": "#8700af", "92": "#8700d7", "93": "#8700ff", "94": "#875f00", "95": "#875f5f", "96": "#875f87", "97": "#875faf", "98": "#875fd7", "99": "#875fff", "100": "#878700", "101": "#87875f", "102": "#878787", "103": "#8787af", "104": "#8787d7", "105": "#8787ff", "106": "#87af00", "107": "#87af5f", "108": "#87af87", "109": "#87afaf", "110": "#87afd7", "111": "#87afff", "112": "#87d700", "113": "#87d75f", "114": "#87d787", "115": "#87d7af", "116": "#87d7d7", "117": "#87d7ff", "118": "#87ff00", "119": "#87ff5f", "120": "#87ff87", "121": "#87ffaf", "122": "#87ffd7", "123": "#87ffff", "124": "#af0000", "125": "#af005f", "126": "#af0087", "127": "#af00af", "128": "#af00d7", "129": "#af00ff", "130": "#af5f00", "131": "#af5f5f", "132": "#af5f87", "133": "#af5faf", "134": "#af5fd7", "135": "#af5fff", "136": "#af8700", "137": "#af875f", "138": "#af8787", "139": "#af87af", "140": "#af87d7", "141": "#af87ff", "142": "#afaf00", "143": "#afaf5f", "144": "#afaf87", "145": "#afafaf", "146": "#afafd7", "147": "#afafff", "148": "#afd700", "149": "#afd75f", "150": "#afd787", "151": "#afd7af", "152": "#afd7d7", "153": "#afd7ff", "154": "#afff00", "155": "#afff5f", "156": "#afff87", "157": "#afffaf", "158": "#afffd7", "159": "#afffff", "160": "#d70000", "161": "#d7005f", "162": "#d70087", "163": "#d700af", "164": "#d700d7", "165": "#d700ff", "166": "#d75f00", "167": "#d75f5f", "168": "#d75f87", "169": "#d75faf", "170": "#d75fd7", "171": "#d75fff", "172": "#d78700", "173": "#d7875f", "174": "#d78787", "175": "#d787af", "176": "#d787d7", "177": "#d787ff", "178": "#d7af00", "179": "#d7af5f", "180": "#d7af87", "181": "#d7afaf", "182": "#d7afd7", "183": "#d7afff", "184": "#d7d700", "185": "#d7d75f", "186": "#d7d787", "187": "#d7d7af", "188": "#d7d7d7", "189": "#d7d7ff", "190": "#d7ff00", "191": "#d7ff5f", "192": "#d7ff87", "193": "#d7ffaf", "194": "#d7ffd7", "195": "#d7ffff", "196": "#ff0000", "197": "#ff005f", "198": "#ff0087", "199": "#ff00af", "200": "#ff00d7", "201": "#ff00ff", "202": "#ff5f00", "203": "#ff5f5f", "204": "#ff5f87", "205": "#ff5faf", "206": "#ff5fd7", "207": "#ff5fff", "208": "#ff8700", "209": "#ff875f", "210": "#ff8787", "211": "#ff87af", "212": "#ff87d7", "213": "#ff87ff", "214": "#ffaf00", "215": "#ffaf5f", "216": "#ffaf87", "217": "#ffafaf", "218": "#ffafd7", "219": "#ffafff", "220": "#ffd700", "221": "#ffd75f", "222": "#ffd787", "223": "#ffd7af", "224": "#ffd7d7", "225": "#ffd7ff", "226": "#ffff00", "227": "#ffff5f", "228": "#ffff87", "229": "#ffffaf", "230": "#ffffd7", "231": "#ffffff", "232": "#080808", "233": "#121212", "234": "#1c1c1c", "235": "#262626", "236": "#303030", "237": "#3a3a3a", "238": "#444444", "239": "#4e4e4e", "240": "#585858", "241": "#626262", "242": "#6c6c6c", "243": "#767676", "244": "#808080", "245": "#8a8a8a", "246": "#949494", "247": "#9e9e9e", "248": "#a8a8a8", "249": "#b2b2b2", "250": "#bcbcbc", "251": "#c6c6c6", "252": "#d0d0d0", "253": "#dadada", "254": "#e4e4e4", "255": "#eeeeee" } # yapf: enable if color in weechat_basic_colors: return hex_colors[weechat_basic_colors[color]] return hex_colors[color] class WeechatFormatter(Formatter): def __init__(self, **options): Formatter.__init__(self, **options) self.styles = {} for token, style in self.style: start = end = "" if style["color"]: start += "{}".format( W.color(color_html_to_weechat(str(style["color"]))) ) end = "{}".format(W.color("resetcolor")) + end if style["bold"]: start += W.color("bold") end = W.color("-bold") + end if style["italic"]: start += W.color("italic") end = W.color("-italic") + end if style["underline"]: start += W.color("underline") end = W.color("-underline") + end self.styles[token] = (start, end) def format(self, tokensource, outfile): lastval = "" lasttype = None for ttype, value in tokensource: while ttype not in self.styles: ttype = ttype.parent if ttype == lasttype: lastval += value else: if lastval: stylebegin, styleend = self.styles[lasttype] outfile.write(stylebegin + lastval + styleend) # set lastval/lasttype to current values lastval = value lasttype = ttype if lastval: stylebegin, styleend = self.styles[lasttype] outfile.write(stylebegin + lastval + styleend) weechat-matrix-0.3.0/matrix/commands.py000066400000000000000000001703561407004440000201200ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Copyright © 2018, 2019 Damir Jelić # Copyright © 2018, 2019 Denis Kasak # # Permission to use, copy, modify, and/or distribute this software for # any purpose with or without fee is hereby granted, provided that the # above copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. from __future__ import unicode_literals import argparse import os import re import shlex from builtins import str from future.moves.itertools import zip_longest from collections import defaultdict from functools import partial from nio import EncryptionError, LocalProtocolError from . import globals as G from .colors import Formatted from .globals import SERVERS, W, UPLOADS, SCRIPT_NAME from .server import MatrixServer from .utf import utf8_decode from .utils import key_from_value, parse_redact_args from .uploads import UploadsBuffer, Upload try: from urllib.parse import urlparse except ImportError: from urlparse import urlparse # type: ignore class ParseError(Exception): pass class WeechatArgParse(argparse.ArgumentParser): def print_usage(self, file=None): pass def error(self, message): message = ( "{prefix}Error: {message} for command {command} " "(see /help {command})" ).format(prefix=W.prefix("error"), message=message, command=self.prog) W.prnt("", message) raise ParseError class WeechatCommandParser(object): @staticmethod def _run_parser(parser, args): try: parsed_args = parser.parse_args(shlex.split(args)) return parsed_args except ParseError: return None @staticmethod def topic(args): parser = WeechatArgParse(prog="topic") parser.add_argument("-delete", action="store_true") parser.add_argument("topic", nargs="*") return WeechatCommandParser._run_parser(parser, args) @staticmethod def kick(args): parser = WeechatArgParse(prog="kick") parser.add_argument("user_id") parser.add_argument("reason", nargs="*") return WeechatCommandParser._run_parser(parser, args) @staticmethod def invite(args): parser = WeechatArgParse(prog="invite") parser.add_argument("user_id") return WeechatCommandParser._run_parser(parser, args) @staticmethod def join(args): parser = WeechatArgParse(prog="join") parser.add_argument("room_id") return WeechatCommandParser._run_parser(parser, args) @staticmethod def part(args): parser = WeechatArgParse(prog="part") parser.add_argument("room_id", nargs="?") return WeechatCommandParser._run_parser(parser, args) @staticmethod def devices(args): parser = WeechatArgParse(prog="devices") subparsers = parser.add_subparsers(dest="subcommand") subparsers.add_parser("list") delete_parser = subparsers.add_parser("delete") delete_parser.add_argument("device_id") name_parser = subparsers.add_parser("set-name") name_parser.add_argument("device_id") name_parser.add_argument("device_name", nargs="*") return WeechatCommandParser._run_parser(parser, args) @staticmethod def olm(args): parser = WeechatArgParse(prog="olm") subparsers = parser.add_subparsers(dest="subcommand") info_parser = subparsers.add_parser("info") info_parser.add_argument( "category", nargs="?", default="private", choices=[ "all", "blacklisted", "private", "unverified", "verified", "ignored" ]) info_parser.add_argument("filter", nargs="?") verify_parser = subparsers.add_parser("verify") verify_parser.add_argument("user_filter") verify_parser.add_argument("device_filter", nargs="?") unverify_parser = subparsers.add_parser("unverify") unverify_parser.add_argument("user_filter") unverify_parser.add_argument("device_filter", nargs="?") blacklist_parser = subparsers.add_parser("blacklist") blacklist_parser.add_argument("user_filter") blacklist_parser.add_argument("device_filter", nargs="?") unblacklist_parser = subparsers.add_parser("unblacklist") unblacklist_parser.add_argument("user_filter") unblacklist_parser.add_argument("device_filter", nargs="?") ignore_parser = subparsers.add_parser("ignore") ignore_parser.add_argument("user_filter") ignore_parser.add_argument("device_filter", nargs="?") unignore_parser = subparsers.add_parser("unignore") unignore_parser.add_argument("user_filter") unignore_parser.add_argument("device_filter", nargs="?") export_parser = subparsers.add_parser("export") export_parser.add_argument("file") export_parser.add_argument("passphrase") import_parser = subparsers.add_parser("import") import_parser.add_argument("file") import_parser.add_argument("passphrase") sas_parser = subparsers.add_parser("verification") sas_parser.add_argument( "action", choices=[ "start", "accept", "confirm", "cancel", ]) sas_parser.add_argument("user_id") sas_parser.add_argument("device_id") return WeechatCommandParser._run_parser(parser, args) @staticmethod def room(args): parser = WeechatArgParse(prog="room") subparsers = parser.add_subparsers(dest="subcommand") typing_notification = subparsers.add_parser("typing-notifications") typing_notification.add_argument( "state", choices=["enable", "disable", "toggle"] ) read_markers = subparsers.add_parser("read-markers") read_markers.add_argument( "state", choices=["enable", "disable", "toggle"] ) return WeechatCommandParser._run_parser(parser, args) @staticmethod def uploads(args): parser = WeechatArgParse(prog="uploads") subparsers = parser.add_subparsers(dest="subcommand") subparsers.add_parser("list") subparsers.add_parser("listfull") subparsers.add_parser("up") subparsers.add_parser("down") return WeechatCommandParser._run_parser(parser, args) @staticmethod def upload(args): parser = WeechatArgParse(prog="upload") parser.add_argument("file") return WeechatCommandParser._run_parser(parser, args) def grouper(iterable, n, fillvalue=None): "Collect data into fixed-length chunks or blocks" # grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx" args = [iter(iterable)] * n return zip_longest(*args, fillvalue=fillvalue) def partition_key(key): groups = grouper(key, 4, " ") return ' '.join(''.join(g) for g in groups) def hook_commands(): W.hook_command( # Command name and short description "matrix", "Matrix chat protocol command", # Synopsis ( "server add [:] ||" "server delete|list|listfull ||" "connect ||" "disconnect ||" "reconnect ||" "help " ), # Description ( " server: list, add, or remove Matrix servers\n" " connect: connect to Matrix servers\n" "disconnect: disconnect from one or all Matrix servers\n" " reconnect: reconnect to server(s)\n" " help: show detailed command help\n\n" "Use /matrix help [command] to find out more.\n" ), # Completions ( "server %(matrix_server_commands)|%* ||" "connect %(matrix_servers) ||" "disconnect %(matrix_servers) ||" "reconnect %(matrix_servers) ||" "help %(matrix_commands)" ), # Function name "matrix_command_cb", "", ) W.hook_command( # Command name and short description "redact", "redact messages", # Synopsis ('[:""] []'), # Description ( " event-id: event id of the message that will be redacted\n" "message-part: an initial part of the message (ignored, only " "used\n" " as visual feedback when using completion)\n" " reason: the redaction reason\n" ), # Completions ("%(matrix_messages)"), # Function name "matrix_redact_command_cb", "", ) W.hook_command( # Command name and short description "reply-matrix", "reply to a message", # Synopsis ('[:""] []'), # Description ( " event-id: event id of the message that will be replied to\n" "message-part: an initial part of the message (ignored, only " "used\n" " as visual feedback when using completion)\n" " reply: the reply\n" ), # Completions ("%(matrix_messages)"), # Function name "matrix_reply_command_cb", "", ) W.hook_command( # Command name and short description "topic", "get/set the room topic", # Synopsis ("[|-delete]"), # Description (" topic: topic to set\n" "-delete: delete room topic"), # Completions "", # Callback "matrix_topic_command_cb", "", ) W.hook_command( # Command name and short description "me", "send an emote message to the current room", # Synopsis (""), # Description ("message: message to send"), # Completions "", # Callback "matrix_me_command_cb", "", ) W.hook_command( # Command name and short description "kick", "kick a user from the current room", # Synopsis (" []"), # Description ( "user-id: user-id to kick\n" " reason: reason why the user was kicked" ), # Completions ("%(matrix_users)"), # Callback "matrix_kick_command_cb", "", ) W.hook_command( # Command name and short description "invite", "invite a user to the current room", # Synopsis (""), # Description ("user-id: user-id to invite"), # Completions ("%(matrix_users)"), # Callback "matrix_invite_command_cb", "", ) W.hook_command( # Command name and short description "join", "join a room", # Synopsis ("|"), # Description ( " room-id: room-id of the room to join\n" "room-alias: room alias of the room to join" ), # Completions "", # Callback "matrix_join_command_cb", "", ) W.hook_command( # Command name and short description "part", "leave a room", # Synopsis ("[]"), # Description (" room-name: room name of the room to leave"), # Completions "", # Callback "matrix_part_command_cb", "", ) W.hook_command( # Command name and short description "devices", "list, delete or rename matrix devices", # Synopsis ("list ||" "delete ||" "set-name " ), # Description ("device-id: device id of the device to delete\n" " name: new device name to set\n"), # Completions ("list ||" "delete %(matrix_own_devices) ||" "set-name %(matrix_own_devices)"), # Callback "matrix_devices_command_cb", "", ) W.hook_command( # Command name and short description "olm", "Matrix olm encryption configuration command", # Synopsis ("info all|blacklisted|ignored|private|unverified|verified ||" "blacklist ||" "unverify ||" "verify ||" "verification start|accept|cancel|confirm ||" "ignore ||" "unignore ||" "export ||" "import " ), # Description (" info: show info about known devices and their keys\n" " blacklist: blacklist a device\n" "unblacklist: unblacklist a device\n" " unverify: unverify a device\n" " verify: verify a device\n" " ignore: ignore an unverifiable but non-blacklist-worthy device\n" " unignore: unignore a device\n" "verification: manage interactive device verification\n" " export: export encryption keys\n" " import: import encryption keys\n\n" "Examples:" "\n /olm verify @example:example.com *" "\n /olm info all example*" ), # Completions ('info all|blacklisted|ignored|private|unverified|verified ||' 'blacklist %(olm_user_ids) %(olm_devices) ||' 'unblacklist %(olm_user_ids) %(olm_devices) ||' 'unverify %(olm_user_ids) %(olm_devices) ||' 'verify %(olm_user_ids) %(olm_devices) ||' 'verification start|accept|cancel|confirm %(olm_user_ids) %(olm_devices) ||' 'ignore %(olm_user_ids) %(olm_devices) ||' 'unignore %(olm_user_ids) %(olm_devices) ||' 'export %(filename) ||' 'import %(filename)' ), # Function name 'matrix_olm_command_cb', '') W.hook_command( # Command name and short description "room", "change room state", # Synopsis ("typing-notifications ||" "read-markers " ), # Description ("state: one of enable, disable or toggle\n"), # Completions ("typing-notifications enable|disable|toggle||" "read-markers enable|disable|toggle" ), # Callback "matrix_room_command_cb", "", ) # W.hook_command( # # Command name and short description # "uploads", # "Open the uploads buffer or list uploads in the core buffer", # # Synopsis # ("list||" # "listfull" # ), # # Description # (""), # # Completions # ("list ||" # "listfull"), # # Callback # "matrix_uploads_command_cb", # "", # ) W.hook_command( # Command name and short description "upload", "Upload a file to a room", # Synopsis (""), # Description (""), # Completions ("%(filename)"), # Callback "matrix_upload_command_cb", "", ) W.hook_command( # Command name and short description "send-anyways", "Send the last message in a room ignorin unverified devices.", # Synopsis "", # Description "Send the last message in a room despite there being unverified " "devices. The unverified devices will be marked as ignored after " "running this command.", # Completions "", # Callback "matrix_send_anyways_cb", "", ) W.hook_command_run("/buffer clear", "matrix_command_buf_clear_cb", "") if G.CONFIG.network.fetch_backlog_on_pgup: hook_page_up() def hook_key_bindings(): W.hook_hsignal("matrix_cursor_reply", "matrix_cursor_reply_signal_cb", "") binding = "@chat(python.{}*):r".format(G.BUFFER_NAME_PREFIX) W.key_bind("cursor", { binding: "hsignal:matrix_cursor_reply", }) def format_device(device_id, fp_key, display_name): fp_key = partition_key(fp_key) message = (" - Device ID: {device_color}{device_id}{ncolor}\n" " - Display name: {device_color}{display_name}{ncolor}\n" " - Device key: {key_color}{fp_key}{ncolor}").format( device_color=W.color("chat_channel"), device_id=device_id, ncolor=W.color("reset"), display_name=display_name, key_color=W.color("chat_server"), fp_key=fp_key) return message def olm_info_command(server, args): def print_devices( device_store, filter_regex, device_category="All", predicate=None, ): user_strings = [] try: filter_regex = re.compile(args.filter) if args.filter else None except re.error as e: server.error("Invalid regular expression: {}.".format(e.args[0])) return for user_id in sorted(device_store.users): device_strings = [] for device in device_store.active_user_devices(user_id): if filter_regex: if (not filter_regex.search(user_id) and not filter_regex.search(device.id)): continue if predicate: if not predicate(device): continue device_strings.append(format_device( device.id, device.ed25519, device.display_name )) if not device_strings: continue d_string = "\n".join(device_strings) message = (" - User: {user_color}{user}{ncolor}\n").format( user_color=W.color("chat_nick"), user=user_id, ncolor=W.color("reset")) message += d_string user_strings.append(message) if not user_strings: message = ("{prefix}matrix: No matching devices " "found.").format(prefix=W.prefix("error")) W.prnt(server.server_buffer, message) return server.info("{} devices:\n".format(device_category)) W.prnt(server.server_buffer, "\n".join(user_strings)) olm = server.client.olm if not hasattr(args, 'category') or args.category == "private": fp_key = partition_key(olm.account.identity_keys["ed25519"]) message = ("Identity keys:\n" " - User: {user_color}{user}{ncolor}\n" " - Device ID: {device_color}{device_id}{ncolor}\n" " - Device key: {key_color}{fp_key}{ncolor}\n" "").format( user_color=W.color("chat_self"), ncolor=W.color("reset"), user=olm.user_id, device_color=W.color("chat_channel"), device_id=olm.device_id, key_color=W.color("chat_server"), fp_key=fp_key) server.info(message) elif args.category == "all": print_devices(olm.device_store, args.filter) elif args.category == "verified": print_devices( olm.device_store, args.filter, "Verified", olm.is_device_verified ) elif args.category == "unverified": def predicate(device): return not olm.is_device_verified(device) print_devices( olm.device_store, args.filter, "Unverified", predicate ) elif args.category == "blacklisted": print_devices( olm.device_store, args.filter, "Blacklisted", olm.is_device_blacklisted ) elif args.category == "ignored": print_devices( olm.device_store, args.filter, "Ignored", olm.is_device_ignored ) def olm_action_command(server, args, category, error_category, prefix, action): device_store = server.client.olm.device_store users = [] if args.user_filter == "*": users = device_store.users else: users = [x for x in device_store.users if args.user_filter in x] user_devices = { user: device_store.active_user_devices(user) for user in users } if args.device_filter and args.device_filter != "*": filtered_user_devices = {} for user, device_list in user_devices.items(): filtered_devices = filter( lambda x: args.device_filter in x.id, device_list ) filtered_user_devices[user] = list(filtered_devices) user_devices = filtered_user_devices changed_devices = defaultdict(list) for user, device_list in user_devices.items(): for device in device_list: if action(device): changed_devices[user].append(device) if not changed_devices: message = ("{prefix}matrix: No matching {error_category} devices " "found.").format( prefix=W.prefix("error"), error_category=error_category ) W.prnt(server.server_buffer, message) return user_strings = [] for user_id, device_list in changed_devices.items(): device_strings = [] message = (" - User: {user_color}{user}{ncolor}\n").format( user_color=W.color("chat_nick"), user=user_id, ncolor=W.color("reset")) for device in device_list: device_strings.append(format_device( device.id, device.ed25519, device.display_name )) if not device_strings: continue d_string = "\n".join(device_strings) message += d_string user_strings.append(message) W.prnt(server.server_buffer, "{}matrix: {} key(s):\n".format(W.prefix("prefix"), category)) W.prnt(server.server_buffer, "\n".join(user_strings)) pass def olm_verify_command(server, args): olm_action_command( server, args, "Verified", "unverified", "join", server.client.verify_device ) def olm_unverify_command(server, args): olm_action_command( server, args, "Unverified", "verified", "quit", server.client.unverify_device ) def olm_blacklist_command(server, args): olm_action_command( server, args, "Blacklisted", "unblacklisted", "join", server.client.blacklist_device ) def olm_unblacklist_command(server, args): olm_action_command( server, args, "Unblacklisted", "blacklisted", "join", server.client.unblacklist_device ) def olm_ignore_command(server, args): olm_action_command( server, args, "Ignored", "ignored", "join", server.client.ignore_device ) def olm_unignore_command(server, args): olm_action_command( server, args, "Unignored", "unignored", "join", server.client.unignore_device ) def olm_export_command(server, args): file_path = os.path.expanduser(args.file) try: server.client.export_keys(file_path, args.passphrase) server.info("Successfully exported keys") except (OSError, IOError) as e: server.error("Error exporting keys: {}".format(str(e))) def olm_import_command(server, args): file_path = os.path.expanduser(args.file) try: server.client.import_keys(file_path, args.passphrase) server.info("Successfully imported keys") except (OSError, IOError, EncryptionError) as e: server.error("Error importing keys: {}".format(str(e))) def olm_sas_command(server, args): try: device_store = server.client.device_store except LocalProtocolError: server.error("The device store is not loaded") return W.WEECHAT_RC_OK try: device = device_store[args.user_id][args.device_id] except KeyError: server.error("Device {} of user {} not found".format( args.device_id, args.user_id )) return W.WEECHAT_RC_OK if device.deleted: server.error("Device {} of user {} is deleted.".format( args.device_id, args.user_id )) return W.WEECHAT_RC_OK if args.action == "start": server.start_verification(device) elif args.action in ["accept", "confirm", "cancel"]: sas = server.client.get_active_sas(args.user_id, args.device_id) if not sas: server.error("No active key verification found for " "device {} of user {}.".format( args.device_id, args.user_id )) return W.WEECHAT_RC_OK try: if args.action == "accept": server.accept_sas(sas) elif args.action == "confirm": server.confirm_sas(sas) elif args.action == "cancel": server.cancel_sas(sas) except LocalProtocolError as e: server.error(str(e)) @utf8_decode def matrix_olm_command_cb(data, buffer, args): def command(server, data, buffer, args): parsed_args = WeechatCommandParser.olm(args) if not parsed_args: return W.WEECHAT_RC_OK if not server.client.olm: W.prnt(server.server_buffer, "{}matrix: Olm account isn't " "loaded.".format(W.prefix("error"))) return W.WEECHAT_RC_OK if not parsed_args.subcommand or parsed_args.subcommand == "info": olm_info_command(server, parsed_args) elif parsed_args.subcommand == "export": olm_export_command(server, parsed_args) elif parsed_args.subcommand == "import": olm_import_command(server, parsed_args) elif parsed_args.subcommand == "verify": olm_verify_command(server, parsed_args) elif parsed_args.subcommand == "unverify": olm_unverify_command(server, parsed_args) elif parsed_args.subcommand == "blacklist": olm_blacklist_command(server, parsed_args) elif parsed_args.subcommand == "unblacklist": olm_unblacklist_command(server, parsed_args) elif parsed_args.subcommand == "verification": olm_sas_command(server, parsed_args) elif parsed_args.subcommand == "ignore": olm_ignore_command(server, parsed_args) elif parsed_args.subcommand == "unignore": olm_unignore_command(server, parsed_args) else: message = ("{prefix}matrix: Command not implemented.".format( prefix=W.prefix("error"))) W.prnt(server.server_buffer, message) W.bar_item_update("buffer_modes") W.bar_item_update("matrix_modes") return W.WEECHAT_RC_OK for server in SERVERS.values(): if buffer in server.buffers.values(): return command(server, data, buffer, args) elif buffer == server.server_buffer: return command(server, data, buffer, args) W.prnt("", "{prefix}matrix: command \"olm\" must be executed on a " "matrix buffer (server or channel)".format( prefix=W.prefix("error") )) return W.WEECHAT_RC_OK @utf8_decode def matrix_devices_command_cb(data, buffer, args): for server in SERVERS.values(): if buffer in server.buffers.values() or buffer == server.server_buffer: parsed_args = WeechatCommandParser.devices(args) if not parsed_args: return W.WEECHAT_RC_OK if not parsed_args.subcommand or parsed_args.subcommand == "list": server.devices() elif parsed_args.subcommand == "delete": server.delete_device(parsed_args.device_id) elif parsed_args.subcommand == "set-name": new_name = " ".join(parsed_args.device_name).strip("\"") server.rename_device(parsed_args.device_id, new_name) return W.WEECHAT_RC_OK W.prnt("", "{prefix}matrix: command \"devices\" must be executed on a " "matrix buffer (server or channel)".format( prefix=W.prefix("error") )) return W.WEECHAT_RC_OK @utf8_decode def matrix_me_command_cb(data, buffer, args): for server in SERVERS.values(): if buffer in server.buffers.values(): if not server.connected: message = ( "{prefix}matrix: you are not connected to " "the server" ).format(prefix=W.prefix("error")) W.prnt(server.server_buffer, message) return W.WEECHAT_RC_ERROR room_buffer = server.find_room_from_ptr(buffer) if not server.client.logged_in: room_buffer.error("You are not logged in.") return W.WEECHAT_RC_ERROR if not args: return W.WEECHAT_RC_OK formatted_data = Formatted.from_input_line(args) server.room_send_message(room_buffer, formatted_data, "m.emote") return W.WEECHAT_RC_OK if buffer == server.server_buffer: message = ( '{prefix}matrix: command "me" must be ' "executed on a Matrix channel buffer" ).format(prefix=W.prefix("error")) W.prnt("", message) return W.WEECHAT_RC_OK return W.WEECHAT_RC_OK @utf8_decode def matrix_topic_command_cb(data, buffer, args): parsed_args = WeechatCommandParser.topic(args) if not parsed_args: return W.WEECHAT_RC_OK for server in SERVERS.values(): if buffer == server.server_buffer: server.error( 'command "topic" must be ' "executed on a Matrix room buffer" ) return W.WEECHAT_RC_OK room = server.find_room_from_ptr(buffer) if not room: continue if not parsed_args.topic and not parsed_args.delete: # TODO print the current topic return W.WEECHAT_RC_OK if parsed_args.delete and parsed_args.topic: # TODO error message return W.WEECHAT_RC_OK topic = "" if parsed_args.delete else " ".join(parsed_args.topic) content = {"topic": topic} server.room_send_state(room, content, "m.room.topic") return W.WEECHAT_RC_OK def matrix_fetch_old_messages(server, room_id): room_buffer = server.find_room_from_id(room_id) room = room_buffer.room if room_buffer.backlog_pending: return prev_batch = room.prev_batch if not prev_batch: return raise NotImplementedError def check_server_existence(server_name, servers): if server_name not in servers: message = "{prefix}matrix: No such server: {server}".format( prefix=W.prefix("error"), server=server_name ) W.prnt("", message) return False return True def hook_page_up(): G.CONFIG.page_up_hook = W.hook_command_run( "/window page_up", "matrix_command_pgup_cb", "" ) @utf8_decode def matrix_command_buf_clear_cb(data, buffer, command): for server in SERVERS.values(): if buffer in server.buffers.values(): room_buffer = server.find_room_from_ptr(buffer) room_buffer.room.prev_batch = server.next_batch return W.WEECHAT_RC_OK return W.WEECHAT_RC_OK @utf8_decode def matrix_command_pgup_cb(data, buffer, command): # TODO the highlight status of a line isn't allowed to be updated/changed # via hdata, therefore the highlight status of a messages can't be # reoredered this would need to be fixed in weechat # TODO we shouldn't fetch and print out more messages than # max_buffer_lines_number or older messages than max_buffer_lines_minutes for server in SERVERS.values(): if buffer in server.buffers.values(): window = W.window_search_with_buffer(buffer) first_line_displayed = bool( W.window_get_integer(window, "first_line_displayed") ) room_buffer = server.find_room_from_ptr(buffer) if first_line_displayed or room_buffer.weechat_buffer.num_lines == 0: room_id = key_from_value(server.buffers, buffer) server.room_get_messages(room_id) return W.WEECHAT_RC_OK return W.WEECHAT_RC_OK @utf8_decode def matrix_join_command_cb(data, buffer, args): parsed_args = WeechatCommandParser.join(args) if not parsed_args: return W.WEECHAT_RC_OK for server in SERVERS.values(): if buffer in server.buffers.values() or buffer == server.server_buffer: server.room_join(parsed_args.room_id) break return W.WEECHAT_RC_OK @utf8_decode def matrix_part_command_cb(data, buffer, args): parsed_args = WeechatCommandParser.part(args) if not parsed_args: return W.WEECHAT_RC_OK for server in SERVERS.values(): if buffer in server.buffers.values() or buffer == server.server_buffer: room_id = parsed_args.room_id if not room_id: if buffer == server.server_buffer: server.error( 'command "part" must be ' "executed on a Matrix room buffer or a room " "name needs to be given" ) return W.WEECHAT_RC_OK room_buffer = server.find_room_from_ptr(buffer) room_id = room_buffer.room.room_id server.room_leave(room_id) break return W.WEECHAT_RC_OK @utf8_decode def matrix_invite_command_cb(data, buffer, args): parsed_args = WeechatCommandParser.invite(args) if not parsed_args: return W.WEECHAT_RC_OK for server in SERVERS.values(): if buffer == server.server_buffer: server.error( 'command "invite" must be ' "executed on a Matrix room buffer" ) return W.WEECHAT_RC_OK room = server.find_room_from_ptr(buffer) if not room: continue user_id = parsed_args.user_id user_id = user_id if user_id.startswith("@") else "@" + user_id server.room_invite(room, user_id) break return W.WEECHAT_RC_OK @utf8_decode def matrix_room_command_cb(data, buffer, args): parsed_args = WeechatCommandParser.room(args) if not parsed_args: return W.WEECHAT_RC_OK for server in SERVERS.values(): if buffer == server.server_buffer: server.error( 'command "room" must be ' "executed on a Matrix room buffer" ) return W.WEECHAT_RC_OK room = server.find_room_from_ptr(buffer) if not room: continue if not parsed_args.subcommand or parsed_args.subcommand == "list": server.error("command no subcommand found") return W.WEECHAT_RC_OK if parsed_args.subcommand == "typing-notifications": if parsed_args.state == "enable": room.typing_enabled = True elif parsed_args.state == "disable": room.typing_enabled = False elif parsed_args.state == "toggle": room.typing_enabled = not room.typing_enabled break elif parsed_args.subcommand == "read-markers": if parsed_args.state == "enable": room.read_markers_enabled = True elif parsed_args.state == "disable": room.read_markers_enabled = False elif parsed_args.state == "toggle": room.read_markers_enabled = not room.read_markers_enabled break return W.WEECHAT_RC_OK @utf8_decode def matrix_uploads_command_cb(data, buffer, args): if not args: if not G.CONFIG.upload_buffer: G.CONFIG.upload_buffer = UploadsBuffer() G.CONFIG.upload_buffer.display() return W.WEECHAT_RC_OK parsed_args = WeechatCommandParser.uploads(args) if not parsed_args: return W.WEECHAT_RC_OK if parsed_args.subcommand == "list": pass elif parsed_args.subcommand == "listfull": pass elif parsed_args.subcommand == "up": if G.CONFIG.upload_buffer: G.CONFIG.upload_buffer.move_line_up() elif parsed_args.subcommand == "down": if G.CONFIG.upload_buffer: G.CONFIG.upload_buffer.move_line_down() return W.WEECHAT_RC_OK @utf8_decode def matrix_upload_command_cb(data, buffer, args): parsed_args = WeechatCommandParser.upload(args) if not parsed_args: return W.WEECHAT_RC_OK for server in SERVERS.values(): if buffer == server.server_buffer: server.error( 'command "upload" must be ' "executed on a Matrix room buffer" ) return W.WEECHAT_RC_OK room_buffer = server.find_room_from_ptr(buffer) if not room_buffer: continue upload = Upload( server.name, server.config.address, server.client.access_token, room_buffer.room.room_id, parsed_args.file, room_buffer.room.encrypted ) UPLOADS[upload.uuid] = upload if G.CONFIG.upload_buffer: G.CONFIG.upload_buffer.render() break return W.WEECHAT_RC_OK @utf8_decode def matrix_kick_command_cb(data, buffer, args): parsed_args = WeechatCommandParser.kick(args) if not parsed_args: return W.WEECHAT_RC_OK for server in SERVERS.values(): if buffer == server.server_buffer: server.error( 'command "kick" must be ' "executed on a Matrix room buffer" ) return W.WEECHAT_RC_OK room = server.find_room_from_ptr(buffer) if not room: continue user_id = parsed_args.user_id user_id = user_id if user_id.startswith("@") else "@" + user_id reason = " ".join(parsed_args.reason) if parsed_args.reason else None server.room_kick(room, user_id, reason) break return W.WEECHAT_RC_OK @utf8_decode def matrix_redact_command_cb(data, buffer, args): def already_redacted(line): if SCRIPT_NAME + "_redacted" in line.tags: return True return False def predicate(event_id, line): event_tag = SCRIPT_NAME + "_id_{}".format(event_id) tags = line.tags if event_tag in tags: return True return False for server in SERVERS.values(): if buffer in server.buffers.values(): room_buffer = server.find_room_from_ptr(buffer) event_id, reason = parse_redact_args(args) if not event_id: message = ( "{prefix}matrix: Invalid command " "arguments (see /help redact)" ).format(prefix=W.prefix("error")) W.prnt("", message) return W.WEECHAT_RC_ERROR lines = room_buffer.weechat_buffer.find_lines( partial(predicate, event_id), max_lines=1 ) if not lines: room_buffer.error( "No such message with event id " "{event_id} found.".format(event_id=event_id)) return W.WEECHAT_RC_OK if already_redacted(lines[0]): room_buffer.error("Event already redacted.") return W.WEECHAT_RC_OK server.room_send_redaction(room_buffer, event_id, reason) return W.WEECHAT_RC_OK if buffer == server.server_buffer: message = ( '{prefix}matrix: command "redact" must be ' "executed on a Matrix channel buffer" ).format(prefix=W.prefix("error")) W.prnt("", message) return W.WEECHAT_RC_OK return W.WEECHAT_RC_OK @utf8_decode def matrix_reply_command_cb(data, buffer, args): def predicate(event_id, line): event_tag = SCRIPT_NAME + "_id_{}".format(event_id) tags = line.tags if event_tag in tags: return True return False for server in SERVERS.values(): if buffer in server.buffers.values(): room_buffer = server.find_room_from_ptr(buffer) # Intentional use of `parse_redact_args` which serves the # necessary purpose event_id, reply = parse_redact_args(args) if not event_id or not reply: message = ( "{prefix}matrix: Invalid command " "arguments (see /help reply)" ).format(prefix=W.prefix("error")) W.prnt("", message) return W.WEECHAT_RC_ERROR lines = room_buffer.weechat_buffer.find_lines( partial(predicate, event_id), max_lines=1 ) if not lines: room_buffer.error( "No such message with event id " "{event_id} found.".format(event_id=event_id)) return W.WEECHAT_RC_OK formatted_data = Formatted.from_input_line(reply) server.room_send_message( room_buffer, formatted_data, "m.text", in_reply_to_event_id=event_id, ) room_buffer.last_message = None return W.WEECHAT_RC_OK if buffer == server.server_buffer: message = ( '{prefix}matrix: command "reply" must be ' "executed on a Matrix channel buffer" ).format(prefix=W.prefix("error")) W.prnt("", message) return W.WEECHAT_RC_OK return W.WEECHAT_RC_OK def matrix_command_help(args): if not args: message = ( "{prefix}matrix: Too few arguments for command " '"/matrix help" (see /matrix help help)' ).format(prefix=W.prefix("error")) W.prnt("", message) return for command in args: message = "" if command == "connect": message = ( "{delimiter_color}[{ncolor}matrix{delimiter_color}] " "{ncolor}{cmd_color}/connect{ncolor} " " [...]" "\n\n" "connect to Matrix server(s)" "\n\n" "server-name: server to connect to" "(internal name)" ).format( delimiter_color=W.color("chat_delimiters"), cmd_color=W.color("chat_buffer"), ncolor=W.color("reset"), ) elif command == "disconnect": message = ( "{delimiter_color}[{ncolor}matrix{delimiter_color}] " "{ncolor}{cmd_color}/disconnect{ncolor} " " [...]" "\n\n" "disconnect from Matrix server(s)" "\n\n" "server-name: server to disconnect" "(internal name)" ).format( delimiter_color=W.color("chat_delimiters"), cmd_color=W.color("chat_buffer"), ncolor=W.color("reset"), ) elif command == "reconnect": message = ( "{delimiter_color}[{ncolor}matrix{delimiter_color}] " "{ncolor}{cmd_color}/reconnect{ncolor} " " [...]" "\n\n" "reconnect to Matrix server(s)" "\n\n" "server-name: server to reconnect" "(internal name)" ).format( delimiter_color=W.color("chat_delimiters"), cmd_color=W.color("chat_buffer"), ncolor=W.color("reset"), ) elif command == "server": message = ( "{delimiter_color}[{ncolor}matrix{delimiter_color}] " "{ncolor}{cmd_color}/server{ncolor} " "add [:]" "\n " "delete|list|listfull " "\n\n" "list, add, or remove Matrix servers" "\n\n" " list: list servers (without argument, this " "list is displayed)\n" " listfull: list servers with detailed info for each " "server\n" " add: add a new server\n" " delete: delete a server\n" "server-name: server to reconnect (internal name)\n" " hostname: name or IP address of server\n" " port: port of server (default: 443)\n" "\n" "Examples:" "\n /matrix server listfull" "\n /matrix server add matrix matrix.org:80" "\n /matrix server delete matrix" ).format( delimiter_color=W.color("chat_delimiters"), cmd_color=W.color("chat_buffer"), ncolor=W.color("reset"), ) elif command == "help": message = ( "{delimiter_color}[{ncolor}matrix{delimiter_color}] " "{ncolor}{cmd_color}/help{ncolor} " " [...]" "\n\n" "display help about Matrix commands" "\n\n" "matrix-command: a Matrix command name" "(internal name)" ).format( delimiter_color=W.color("chat_delimiters"), cmd_color=W.color("chat_buffer"), ncolor=W.color("reset"), ) else: message = ( '{prefix}matrix: No help available, "{command}" ' "is not a matrix command" ).format(prefix=W.prefix("error"), command=command) W.prnt("", "") W.prnt("", message) return def matrix_server_command_listfull(args): def get_value_string(value, default_value): if value == default_value: if not value: value = "''" value_string = " ({value})".format(value=value) else: value_string = "{color}{value}{ncolor}".format( color=W.color("chat_value"), value=value, ncolor=W.color("reset"), ) return value_string for server_name in args: if server_name not in SERVERS: continue server = SERVERS[server_name] connected = "" W.prnt("", "") if server.connected: connected = "connected" else: connected = "not connected" message = ( "Server: {server_color}{server}{delimiter_color}" " [{ncolor}{connected}{delimiter_color}]" "{ncolor}" ).format( server_color=W.color("chat_server"), server=server.name, delimiter_color=W.color("chat_delimiters"), connected=connected, ncolor=W.color("reset"), ) W.prnt("", message) option = server.config._option_ptrs["autoconnect"] default_value = W.config_string_default(option) value = W.config_string(option) value_string = get_value_string(value, default_value) message = " autoconnect. : {value}".format(value=value_string) W.prnt("", message) option = server.config._option_ptrs["address"] default_value = W.config_string_default(option) value = W.config_string(option) value_string = get_value_string(value, default_value) message = " address. . . : {value}".format(value=value_string) W.prnt("", message) option = server.config._option_ptrs["port"] default_value = str(W.config_integer_default(option)) value = str(W.config_integer(option)) value_string = get_value_string(value, default_value) message = " port . . . . : {value}".format(value=value_string) W.prnt("", message) option = server.config._option_ptrs["username"] default_value = W.config_string_default(option) value = W.config_string(option) value_string = get_value_string(value, default_value) message = " username . . : {value}".format(value=value_string) W.prnt("", message) option = server.config._option_ptrs["password"] value = W.config_string(option) if value: value = "(hidden)" value_string = get_value_string(value, "") message = " password . . : {value}".format(value=value_string) W.prnt("", message) def matrix_server_command_delete(args): for server_name in args: if check_server_existence(server_name, SERVERS): server = SERVERS[server_name] if server.connected: message = ( "{prefix}matrix: you can not delete server " "{color}{server}{ncolor} because you are " 'connected to it. Try "/matrix disconnect ' '{color}{server}{ncolor}" before.' ).format( prefix=W.prefix("error"), color=W.color("chat_server"), ncolor=W.color("reset"), server=server.name, ) W.prnt("", message) return for buf in list(server.buffers.values()): W.buffer_close(buf) if server.server_buffer: W.buffer_close(server.server_buffer) for option in server.config._option_ptrs.values(): W.config_option_free(option) if server.timer_hook: W.unhook(server.timer_hook) server.timer_hook = None message = ( "matrix: server {color}{server}{ncolor} has been " "deleted" ).format( server=server.name, color=W.color("chat_server"), ncolor=W.color("reset"), ) del SERVERS[server.name] server = None W.prnt("", message) def matrix_server_command_add(args): if len(args) < 2: message = ( "{prefix}matrix: Too few arguments for command " '"/matrix server add" (see /matrix help server)' ).format(prefix=W.prefix("error")) W.prnt("", message) return if len(args) > 4: message = ( "{prefix}matrix: Too many arguments for command " '"/matrix server add" (see /matrix help server)' ).format(prefix=W.prefix("error")) W.prnt("", message) return def remove_server(server): for option in server.config._option_ptrs.values(): W.config_option_free(option) del SERVERS[server.name] server_name = args[0] if server_name in SERVERS: message = ( "{prefix}matrix: server {color}{server}{ncolor} " "already exists, can't add it" ).format( prefix=W.prefix("error"), color=W.color("chat_server"), server=server_name, ncolor=W.color("reset"), ) W.prnt("", message) return server = MatrixServer(server_name, G.CONFIG._ptr) SERVERS[server.name] = server if len(args) >= 2: if args[1].startswith("http"): homeserver= urlparse(args[1]) host = homeserver.hostname port = str(homeserver.port) if homeserver.port else None else: try: host, port = args[1].split(":", 1) except ValueError: host, port = args[1], None return_code = W.config_option_set( server.config._option_ptrs["address"], host, 1 ) if return_code == W.WEECHAT_CONFIG_OPTION_SET_ERROR: remove_server(server) message = ( "{prefix}Failed to set address for server " "{color}{server}{ncolor}, failed to add " "server." ).format( prefix=W.prefix("error"), color=W.color("chat_server"), server=server.name, ncolor=W.color("reset"), ) W.prnt("", message) server = None return if port: return_code = W.config_option_set( server.config._option_ptrs["port"], port, 1 ) if return_code == W.WEECHAT_CONFIG_OPTION_SET_ERROR: remove_server(server) message = ( "{prefix}Failed to set port for server " "{color}{server}{ncolor}, failed to add " "server." ).format( prefix=W.prefix("error"), color=W.color("chat_server"), server=server.name, ncolor=W.color("reset"), ) W.prnt("", message) server = None return if len(args) >= 3: user = args[2] return_code = W.config_option_set( server.config._option_ptrs["username"], user, 1 ) if return_code == W.WEECHAT_CONFIG_OPTION_SET_ERROR: remove_server(server) message = ( "{prefix}Failed to set user for server " "{color}{server}{ncolor}, failed to add " "server." ).format( prefix=W.prefix("error"), color=W.color("chat_server"), server=server.name, ncolor=W.color("reset"), ) W.prnt("", message) server = None return if len(args) == 4: password = args[3] return_code = W.config_option_set( server.config._option_ptrs["password"], password, 1 ) if return_code == W.WEECHAT_CONFIG_OPTION_SET_ERROR: remove_server(server) message = ( "{prefix}Failed to set password for server " "{color}{server}{ncolor}, failed to add " "server." ).format( prefix=W.prefix("error"), color=W.color("chat_server"), server=server.name, ncolor=W.color("reset"), ) W.prnt("", message) server = None return message = ( "matrix: server {color}{server}{ncolor} " "has been added" ).format( server=server.name, color=W.color("chat_server"), ncolor=W.color("reset"), ) W.prnt("", message) def matrix_server_command(command, args): def list_servers(_): if SERVERS: W.prnt("", "\nAll matrix servers:") for server in SERVERS: W.prnt( "", " {color}{server}".format( color=W.color("chat_server"), server=server ), ) # TODO the argument for list and listfull is used as a match word to # find/filter servers, we're currently match exactly to the whole name if command == "list": list_servers(args) elif command == "listfull": matrix_server_command_listfull(args) elif command == "add": matrix_server_command_add(args) elif command == "delete": matrix_server_command_delete(args) else: message = ( "{prefix}matrix: Error: unknown matrix server command, " '"{command}" (type /matrix help server for help)' ).format(prefix=W.prefix("error"), command=command) W.prnt("", message) @utf8_decode def matrix_command_cb(data, buffer, args): def connect_server(args): for server_name in args: if check_server_existence(server_name, SERVERS): server = SERVERS[server_name] server.connect() def disconnect_server(args): for server_name in args: if check_server_existence(server_name, SERVERS): server = SERVERS[server_name] if server.connected or server.reconnect_time: # W.unhook(server.timer_hook) # server.timer_hook = None server.access_token = "" server.disconnect(reconnect=False) split_args = list(filter(bool, args.split(" "))) if len(split_args) < 1: message = ( "{prefix}matrix: Too few arguments for command " '"/matrix" ' "(see /help matrix)" ).format(prefix=W.prefix("error")) W.prnt("", message) return W.WEECHAT_RC_ERROR command, args = split_args[0], split_args[1:] if command == "connect": connect_server(args) elif command == "disconnect": disconnect_server(args) elif command == "reconnect": disconnect_server(args) connect_server(args) elif command == "server": if len(args) >= 1: subcommand, args = args[0], args[1:] matrix_server_command(subcommand, args) else: matrix_server_command("list", "") elif command == "help": matrix_command_help(args) else: message = ( "{prefix}matrix: Error: unknown matrix command, " '"{command}" (type /help matrix for help)' ).format(prefix=W.prefix("error"), command=command) W.prnt("", message) return W.WEECHAT_RC_OK @utf8_decode def matrix_send_anyways_cb(data, buffer, args): for server in SERVERS.values(): if buffer in server.buffers.values(): room_buffer = server.find_room_from_ptr(buffer) if not server.connected: room_buffer.error("Server is disconnected") break if not server.client.logged_in: room_buffer.error("You are not logged in.") return W.WEECHAT_RC_ERROR if not room_buffer.last_message: room_buffer.error("No previously sent message found.") break server.room_send_message( room_buffer, room_buffer.last_message, "m.text", ignore_unverified_devices=True ) room_buffer.last_message = None break else: message = ( "{prefix}matrix: The 'send-anyways' command needs to be " "run on a matrix room buffer" ).format(prefix=W.prefix("error")) W.prnt("", message) return W.WEECHAT_RC_ERROR @utf8_decode def matrix_cursor_reply_signal_cb(data, signal, ht): tags = ht["_chat_line_tags"].split(",") W.command("", "/cursor stop") if "matrix_message" in tags: for tag in tags: if tag.startswith("matrix_id_"): matrix_id = tag[10:] break else: return W.WEECHAT_RC_OK buffer_name = ht["_buffer_full_name"] bufptr = W.buffer_search("==", buffer_name) current_input = W.buffer_get_string(bufptr, "input") input_pos = W.buffer_get_integer(bufptr, "input_pos") new_prefix = "/reply-matrix {} ".format(matrix_id) W.buffer_set(bufptr, "input", new_prefix + current_input) W.buffer_set(bufptr, "input_pos", str(len(new_prefix) + input_pos)) return W.WEECHAT_RC_OK weechat-matrix-0.3.0/matrix/completion.py000066400000000000000000000225641407004440000204650ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Copyright © 2018, 2019 Damir Jelić # # Permission to use, copy, modify, and/or distribute this software for # any purpose with or without fee is hereby granted, provided that the # above copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. from __future__ import unicode_literals from typing import List, Optional from matrix.globals import SERVERS, W, SCRIPT_NAME from matrix.utf import utf8_decode from matrix.utils import tags_from_line_data from nio import LocalProtocolError def add_servers_to_completion(completion): for server_name in SERVERS: W.hook_completion_list_add( completion, server_name, 0, W.WEECHAT_LIST_POS_SORT ) @utf8_decode def matrix_server_command_completion_cb( data, completion_item, buffer, completion ): buffer_input = W.buffer_get_string(buffer, "input").split() args = buffer_input[1:] commands = ["add", "delete", "list", "listfull"] def complete_commands(): for command in commands: W.hook_completion_list_add( completion, command, 0, W.WEECHAT_LIST_POS_SORT ) if len(args) == 1: complete_commands() elif len(args) == 2: if args[1] not in commands: complete_commands() else: if args[1] == "delete" or args[1] == "listfull": add_servers_to_completion(completion) elif len(args) == 3: if args[1] == "delete" or args[1] == "listfull": if args[2] not in SERVERS: add_servers_to_completion(completion) return W.WEECHAT_RC_OK @utf8_decode def matrix_server_completion_cb(data, completion_item, buffer, completion): add_servers_to_completion(completion) return W.WEECHAT_RC_OK @utf8_decode def matrix_command_completion_cb(data, completion_item, buffer, completion): for command in [ "connect", "disconnect", "reconnect", "server", "help", "debug", ]: W.hook_completion_list_add( completion, command, 0, W.WEECHAT_LIST_POS_SORT ) return W.WEECHAT_RC_OK @utf8_decode def matrix_debug_completion_cb(data, completion_item, buffer, completion): for debug_type in ["messaging", "network", "timing"]: W.hook_completion_list_add( completion, debug_type, 0, W.WEECHAT_LIST_POS_SORT ) return W.WEECHAT_RC_OK # TODO this should be configurable REDACTION_COMP_LEN = 50 @utf8_decode def matrix_message_completion_cb(data, completion_item, buffer, completion): max_events = 500 def redacted_or_not_message(tags): # type: (List[str]) -> bool if SCRIPT_NAME + "_redacted" in tags: return True if SCRIPT_NAME + "_message" not in tags: return True return False def event_id_from_tags(tags): # type: (List[str]) -> Optional[str] for tag in tags: if tag.startswith("matrix_id"): event_id = tag[10:] return event_id return None for server in SERVERS.values(): if buffer in server.buffers.values(): room_buffer = server.find_room_from_ptr(buffer) lines = room_buffer.weechat_buffer.lines added = 0 for line in lines: tags = line.tags if redacted_or_not_message(tags): continue event_id = event_id_from_tags(tags) if not event_id: continue # Make sure we'll be able to reliably detect the end of the # quoted snippet message_fmt = line.message.replace("\\", "\\\\") \ .replace('"', '\\"') if len(message_fmt) > REDACTION_COMP_LEN + 2: message_fmt = message_fmt[:REDACTION_COMP_LEN] + ".." item = ('{event_id}|"{message}"').format( event_id=event_id, message=message_fmt ) W.hook_completion_list_add( completion, item, 0, W.WEECHAT_LIST_POS_END ) added += 1 if added >= max_events: break return W.WEECHAT_RC_OK return W.WEECHAT_RC_OK def server_from_buffer(buffer): for server in SERVERS.values(): if buffer in server.buffers.values(): return server if buffer == server.server_buffer: return server return None @utf8_decode def matrix_olm_user_completion_cb(data, completion_item, buffer, completion): server = server_from_buffer(buffer) if not server: return W.WEECHAT_RC_OK try: device_store = server.client.device_store except LocalProtocolError: return W.WEECHAT_RC_OK for user in device_store.users: W.hook_completion_list_add( completion, user, 0, W.WEECHAT_LIST_POS_SORT ) return W.WEECHAT_RC_OK @utf8_decode def matrix_olm_device_completion_cb(data, completion_item, buffer, completion): server = server_from_buffer(buffer) if not server: return W.WEECHAT_RC_OK try: device_store = server.client.device_store except LocalProtocolError: return W.WEECHAT_RC_OK args = W.hook_completion_get_string(completion, "args") fields = args.split() if len(fields) < 2: return W.WEECHAT_RC_OK user = fields[-1] if user not in device_store.users: return W.WEECHAT_RC_OK for device in device_store.active_user_devices(user): W.hook_completion_list_add( completion, device.id, 0, W.WEECHAT_LIST_POS_SORT ) return W.WEECHAT_RC_OK @utf8_decode def matrix_own_devices_completion_cb( data, completion_item, buffer, completion ): server = server_from_buffer(buffer) if not server: return W.WEECHAT_RC_OK olm = server.client.olm if not olm: return W.WEECHAT_RC_OK W.hook_completion_list_add( completion, olm.device_id, 0, W.WEECHAT_LIST_POS_SORT ) user = olm.user_id if user not in olm.device_store.users: return W.WEECHAT_RC_OK for device in olm.device_store.active_user_devices(user): W.hook_completion_list_add( completion, device.id, 0, W.WEECHAT_LIST_POS_SORT ) return W.WEECHAT_RC_OK @utf8_decode def matrix_user_completion_cb(data, completion_item, buffer, completion): def add_user(completion, user): W.hook_completion_list_add( completion, user, 0, W.WEECHAT_LIST_POS_SORT ) for server in SERVERS.values(): if buffer == server.server_buffer: return W.WEECHAT_RC_OK room_buffer = server.find_room_from_ptr(buffer) if not room_buffer: continue users = room_buffer.room.users users = [user[1:] for user in users] for user in users: add_user(completion, user) return W.WEECHAT_RC_OK @utf8_decode def matrix_room_completion_cb(data, completion_item, buffer, completion): """Completion callback for matrix room names.""" for server in SERVERS.values(): for room_buffer in server.room_buffers.values(): name = room_buffer.weechat_buffer.short_name W.hook_completion_list_add( completion, name, 0, W.WEECHAT_LIST_POS_SORT ) return W.WEECHAT_RC_OK def init_completion(): W.hook_completion( "matrix_server_commands", "Matrix server completion", "matrix_server_command_completion_cb", "", ) W.hook_completion( "matrix_servers", "Matrix server completion", "matrix_server_completion_cb", "", ) W.hook_completion( "matrix_commands", "Matrix command completion", "matrix_command_completion_cb", "", ) W.hook_completion( "matrix_messages", "Matrix message completion", "matrix_message_completion_cb", "", ) W.hook_completion( "matrix_debug_types", "Matrix debugging type completion", "matrix_debug_completion_cb", "", ) W.hook_completion( "olm_user_ids", "Matrix olm user id completion", "matrix_olm_user_completion_cb", "", ) W.hook_completion( "olm_devices", "Matrix olm device id completion", "matrix_olm_device_completion_cb", "", ) W.hook_completion( "matrix_users", "Matrix user id completion", "matrix_user_completion_cb", "", ) W.hook_completion( "matrix_own_devices", "Matrix own devices completion", "matrix_own_devices_completion_cb", "", ) W.hook_completion( "matrix_rooms", "Matrix room name completion", "matrix_room_completion_cb", "", ) weechat-matrix-0.3.0/matrix/config.py000066400000000000000000000676521407004440000175700ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Copyright © 2018, 2019 Damir Jelić # Copyright © 2018, 2019 Denis Kasak # # Permission to use, copy, modify, and/or distribute this software for # any purpose with or without fee is hereby granted, provided that the # above copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. """weechat-matrix Configuration module. This module contains abstractions on top of weechats configuration files and the main script configuration class. To add configuration options refer to MatrixConfig. Server specific configuration options are handled in server.py """ from builtins import super from collections import namedtuple from enum import IntEnum, Enum, unique import logbook import nio from matrix.globals import SCRIPT_NAME, SERVERS, W from matrix.utf import utf8_decode from . import globals as G @unique class RedactType(Enum): STRIKETHROUGH = 0 NOTICE = 1 DELETE = 2 @unique class ServerBufferType(Enum): MERGE_CORE = 0 MERGE = 1 INDEPENDENT = 2 @unique class NewChannelPosition(IntEnum): NONE = 0 NEXT = 1 NEAR_SERVER = 2 nio.logger_group.level = logbook.ERROR class Option( namedtuple( "Option", [ "name", "type", "string_values", "min", "max", "value", "description", "cast_func", "change_callback", ], ) ): """A class representing a new configuration option. An option object is consumed by the ConfigSection class adding configuration options to weechat. """ __slots__ = () def __new__( cls, name, type, string_values, min, max, value, description, cast=None, change_callback=None, ): """ Parameters: name (str): Name of the configuration option type (str): Type of the configuration option, can be one of the supported weechat types: string, boolean, integer, color string_values: (str): A list of string values that the option can accept seprated by | min (int): Minimal value of the option, only used if the type of the option is integer max (int): Maximal value of the option, only used if the type of the option is integer description (str): Description of the configuration option cast (callable): A callable function taking a single value and returning a modified value. Useful to turn the configuration option into an enum while reading it. change_callback(callable): A function that will be called by weechat every time the configuration option is changed. """ return super().__new__( cls, name, type, string_values, min, max, value, description, cast, change_callback, ) @utf8_decode def matrix_config_reload_cb(data, config_file): return W.WEECHAT_RC_OK def change_log_level(category, level): """Change the log level of the underlying nio lib Called every time the user changes the log level or log category configuration option.""" if category == "all": nio.logger_group.level = level elif category == "http": nio.http.logger.level = level elif category == "client": nio.client.logger.level = level elif category == "events": nio.events.logger.level = level elif category == "responses": nio.responses.logger.level = level elif category == "encryption": nio.crypto.logger.level = level @utf8_decode def config_server_buffer_cb(data, option): """Callback for the look.server_buffer option. Is called when the option is changed and merges/splits the server buffer""" for server in SERVERS.values(): server.buffer_merge() return 1 @utf8_decode def config_log_level_cb(data, option): """Callback for the network.debug_level option.""" change_log_level( G.CONFIG.network.debug_category, G.CONFIG.network.debug_level ) return 1 @utf8_decode def config_log_category_cb(data, option): """Callback for the network.debug_category option.""" change_log_level(G.CONFIG.debug_category, logbook.ERROR) G.CONFIG.debug_category = G.CONFIG.network.debug_category change_log_level( G.CONFIG.network.debug_category, G.CONFIG.network.debug_level ) return 1 @utf8_decode def config_pgup_cb(data, option): """Callback for the network.fetch_backlog_on_pgup option. Enables or disables the hook that is run when /window page_up is called""" if G.CONFIG.network.fetch_backlog_on_pgup: if not G.CONFIG.page_up_hook: G.CONFIG.page_up_hook = W.hook_command_run( "/window page_up", "matrix_command_pgup_cb", "" ) else: if G.CONFIG.page_up_hook: W.unhook(G.CONFIG.page_up_hook) G.CONFIG.page_up_hook = None return 1 def level_to_logbook(value): if value == 0: return logbook.ERROR if value == 1: return logbook.WARNING if value == 2: return logbook.INFO if value == 3: return logbook.DEBUG return logbook.ERROR def logbook_category(value): if value == 0: return "all" if value == 1: return "http" if value == 2: return "client" if value == 3: return "events" if value == 4: return "responses" if value == 5: return "encryption" return "all" def parse_nick_prefix_colors(value): """Parses the nick prefix color setting string ("admin=COLOR1;mod=COLOR2;power=COLOR3") into a prefix -> color dict.""" def key_to_prefix(key): if key == "admin": return "&" elif key == "mod": return "@" elif key == "power": return "+" else: return "" prefix_colors = { "&": "lightgreen", "@": "lightgreen", "+": "yellow", } for setting in value.split(";"): # skip malformed settings if "=" not in setting: continue key, color = setting.split("=") prefix = key_to_prefix(key) if prefix: prefix_colors[prefix] = color return prefix_colors def eval_cast(string): """A function that passes a string to weechat which evaluates it using its expression evaluation syntax. Can only be used with strings, useful for passwords or options that contain a formatted string to e.g. add colors. More info here: https://weechat.org/files/doc/stable/weechat_plugin_api.en.html#_string_eval_expression""" return W.string_eval_expression(string, {}, {}, {}) class WeechatConfig(object): """A class representing a weechat configuration file Wraps weechats configuration creation functionality""" def __init__(self, sections): """Create a new weechat configuration file, expects the global SCRIPT_NAME to be defined and a reload callback Parameters: sections (List[Tuple[str, List[Option]]]): List of config sections that will be created for the configuration file. """ self._ptr = W.config_new( SCRIPT_NAME, SCRIPT_NAME + "_config_reload_cb", "" ) for section in sections: name, options = section section_class = ConfigSection.build(name, options) setattr(self, name, section_class(name, self._ptr, options)) def free(self): """Free all the config sections and their options as well as the configuration file. Should be called when the script is unloaded.""" for section in [ getattr(self, a) for a in dir(self) if isinstance(getattr(self, a), ConfigSection) ]: section.free() W.config_free(self._ptr) def read(self): """Read the config file""" return_code = W.config_read(self._ptr) if return_code == W.WEECHAT_CONFIG_READ_OK: return True if return_code == W.WEECHAT_CONFIG_READ_MEMORY_ERROR: return False if return_code == W.WEECHAT_CONFIG_READ_FILE_NOT_FOUND: return True return False class ConfigSection(object): """A class representing a weechat config section. Should not be used on its own, the WeechatConfig class uses this to build config sections.""" @classmethod def build(cls, name, options): def constructor(self, name, config_ptr, options): self._ptr = W.config_new_section( config_ptr, name, 0, 0, "", "", "", "", "", "", "", "", "", "" ) self._config_ptr = config_ptr self._option_ptrs = {} for option in options: self._add_option(option) attributes = { option.name: cls.option_property( option.name, option.type, cast_func=option.cast_func ) for option in options } attributes["__init__"] = constructor section_class = type(name.title() + "Section", (cls,), attributes) return section_class def free(self): W.config_section_free_options(self._ptr) W.config_section_free(self._ptr) def _add_option(self, option): cb = option.change_callback.__name__ if option.change_callback else "" option_ptr = W.config_new_option( self._config_ptr, self._ptr, option.name, option.type, option.description, option.string_values, option.min, option.max, option.value, option.value, 0, "", "", cb, "", "", "", ) self._option_ptrs[option.name] = option_ptr @staticmethod def option_property(name, option_type, evaluate=False, cast_func=None): """Create a property for this class that makes the reading of config option values pythonic. The option will be available as a property with the name of the option. If a cast function was defined for the option the property will pass the option value to the cast function and return its result.""" def bool_getter(self): return bool(W.config_boolean(self._option_ptrs[name])) def str_getter(self): if cast_func: return cast_func(W.config_string(self._option_ptrs[name])) return W.config_string(self._option_ptrs[name]) def str_evaluate_getter(self): return W.string_eval_expression( W.config_string(self._option_ptrs[name]), {}, {}, {} ) def int_getter(self): if cast_func: return cast_func(W.config_integer(self._option_ptrs[name])) return W.config_integer(self._option_ptrs[name]) if option_type in ("string", "color"): if evaluate: return property(str_evaluate_getter) return property(str_getter) if option_type == "boolean": return property(bool_getter) if option_type == "integer": return property(int_getter) class MatrixConfig(WeechatConfig): """Main matrix configuration file. This class defines all the global matrix configuration options. New global options should be added to the constructor of this class under the appropriate section. There are three main sections defined: Look: This section is for options that change the way matrix messages are shown or the way the buffers are shown. Color: This section should mainly be for color options, options that change color schemes or themes should go to the look section. Network: This section is for options that change the way the script behaves, e.g. the way it communicates with the server, it handles responses or any other behavioural change that doesn't fit in the previous sections. There is a special section called server defined which contains per server configuration options. Server options aren't defined here, they need to be added in server.py """ def __init__(self): self.debug_buffer = "" self.upload_buffer = "" self.debug_category = "all" self.page_up_hook = None self.human_buffer_names = None look_options = [ Option( "redactions", "integer", "strikethrough|notice|delete", 0, 0, "strikethrough", ( "Only notice redactions, strike through or delete " "redacted messages" ), RedactType, ), Option( "server_buffer", "integer", "merge_with_core|merge_without_core|independent", 0, 0, "merge_with_core", "Merge server buffers", ServerBufferType, config_server_buffer_cb, ), Option( "new_channel_position", "integer", "none|next|near_server", min(NewChannelPosition), max(NewChannelPosition), "none", "force position of new channel in list of buffers " "(none = default position (should be last buffer), " "next = current buffer + 1, near_server = after last " "channel/pv of server)", NewChannelPosition, ), Option( "max_typing_notice_item_length", "integer", "", 10, 1000, "50", ("Limit the length of the typing notice bar item."), ), Option( "bar_item_typing_notice_prefix", "string", "", 0, 0, "Typing: ", ("Prefix for the typing notice bar item."), ), Option( "encryption_warning_sign", "string", "", 0, 0, "⚠️ ", ("A sign that is used to signal trust issues in encrypted " "rooms (note: content is evaluated, see /help eval)"), eval_cast, ), Option( "busy_sign", "string", "", 0, 0, "⏳", ("A sign that is used to signal that the client is busy e.g. " "when the room backlog is fetching" " (note: content is evaluated, see /help eval)"), eval_cast, ), Option( "encrypted_room_sign", "string", "", 0, 0, "🔐", ("A sign that is used to show that the current room is " "encrypted " "(note: content is evaluated, see /help eval)"), eval_cast, ), Option( "disconnect_sign", "string", "", 0, 0, "❌", ("A sign that is used to show that the server is disconnected " "(note: content is evaluated, see /help eval)"), eval_cast, ), Option( "pygments_style", "string", "", 0, 0, "native", "Pygments style to use for highlighting source code blocks", ), Option( "code_blocks", "boolean", "", 0, 0, "on", ("Display preformatted code blocks as rectangular areas by " "padding them with whitespace up to the length of the longest" " line (with optional margin)"), ), Option( "code_block_margin", "integer", "", 0, 100, "2", ("Number of spaces to add as a margin around around a code " "block"), ), Option( "quote_wrap", "integer", "", -1, 1000, "67", ("After how many characters to soft-wrap lines in a quote " "block (reply message). Set to -1 to disable soft-wrapping."), ), Option( "human_buffer_names", "boolean", "", 0, 0, "off", ("If turned on the buffer name will consist of the server " "name and the room name instead of the Matrix room ID. Note, " "this requires a change to the logger.file.mask setting " "since conflicts can happen otherwise " "(requires a script reload)."), ), Option( "markdown_input", "boolean", "", 0, 0, "on", ("If turned on, markdown usage in messages will be converted " "to actual markup (**bold**, *italic*, _italic_, `code`)."), ), ] network_options = [ Option( "max_initial_sync_events", "integer", "", 1, 10000, "30", ("How many events to fetch during the initial sync"), ), Option( "max_backlog_sync_events", "integer", "", 1, 100, "10", ("How many events to fetch during backlog fetching"), ), Option( "fetch_backlog_on_pgup", "boolean", "", 0, 0, "on", ("Fetch messages in the backlog on a window page up event"), None, config_pgup_cb, ), Option( "debug_level", "integer", "error|warn|info|debug", 0, 0, "error", "Enable network protocol debugging.", level_to_logbook, config_log_level_cb, ), Option( "debug_category", "integer", "all|http|client|events|responses|encryption", 0, 0, "all", "Debugging category", logbook_category, config_log_category_cb, ), Option( "debug_buffer", "boolean", "", 0, 0, "off", ("Use a separate buffer for debug logs."), ), Option( "lazy_load_room_users", "boolean", "", 0, 0, "off", ("If on, room users won't be loaded in the background " "proactively, they will be loaded when the user switches to " "the room buffer. This only affects non-encrypted rooms."), ), Option( "max_nicklist_users", "integer", "", 100, 20000, "5000", ("Limit the number of users that are added to the nicklist. " "Active users and users with a higher power level are always." " Inactive users will be removed from the nicklist after a " "day of inactivity."), ), Option( "lag_reconnect", "integer", "", 5, 604800, "90", ("Reconnect to the server if the lag is greater than this " "value (in seconds)"), ), Option( "autoreconnect_delay_growing", "integer", "", 1, 100, "2", ("growing factor for autoreconnect delay to server " "(1 = always same delay, 2 = delay*2 for each retry, etc.)"), ), Option( "autoreconnect_delay_max", "integer", "", 0, 604800, "600", ("maximum autoreconnect delay to server " "(in seconds, 0 = no maximum)"), ), Option( "print_unconfirmed_messages", "boolean", "", 0, 0, "on", ("If off, messages are only printed after the server confirms " "their receival. If on, messages are immediately printed but " "colored differently until receival is confirmed."), ), Option( "lag_min_show", "integer", "", 1, 604800, "500", ("minimum lag to show (in milliseconds)"), ), Option( "typing_notice_conditions", "string", "", 0, 0, "${typing_enabled}", ("conditions to send typing notifications (note: content is " "evaluated, see /help eval); besides the buffer and window " "variables the typing_enabled variable is also expanded; " "the typing_enabled variable can be manipulated with the " "/room command, see /help room"), ), Option( "read_markers_conditions", "string", "", 0, 0, "${markers_enabled}", ("conditions to send read markers (note: content is " "evaluated, see /help eval); besides the buffer and window " "variables the markers_enabled variable is also expanded; " "the markers_enabled variable can be manipulated with the " "/room command, see /help room"), ), Option( "resending_ignores_devices", "boolean", "", 0, 0, "on", ("If on resending the same message to a room that contains " "unverified devices will mark the devices as ignored and " "continue sending the message. If off resending the message " "will again fail and devices need to be marked as verified " "one by one or the /send-anyways command needs to be used to " "ignore them."), ), ] color_options = [ Option( "quote_fg", "color", "", 0, 0, "lightgreen", "Foreground color for matrix style blockquotes", ), Option( "quote_bg", "color", "", 0, 0, "default", "Background counterpart of quote_fg", ), Option( "error_message_fg", "color", "", 0, 0, "darkgray", ("Foreground color for error messages that appear inside a " "room buffer (e.g. when a message errors out when sending or " "when a message is redacted)"), ), Option( "error_message_bg", "color", "", 0, 0, "default", "Background counterpart of error_message_fg.", ), Option( "unconfirmed_message_fg", "color", "", 0, 0, "darkgray", ("Foreground color for messages that are printed out but the " "server hasn't confirmed the that he received them."), ), Option( "unconfirmed_message_bg", "color", "", 0, 0, "default", "Background counterpart of unconfirmed_message_fg." ), Option( "untagged_code_fg", "color", "", 0, 0, "blue", ("Foreground color for code without a language specifier. " "Also used for `inline code`."), ), Option( "untagged_code_bg", "color", "", 0, 0, "default", "Background counterpart of untagged_code_fg", ), Option( "nick_prefixes", "string", "", 0, 0, "admin=lightgreen;mod=lightgreen;power=yellow", ('Colors for nick prefixes indicating power level. ' 'Format is "admin:color1;mod:color2;power:color3", ' 'where "admin" stands for admins (power level = 100), ' '"mod" stands for moderators (power level >= 50) and ' '"power" for any other power user (power level > 0). ' 'Requires restart to apply changes.'), parse_nick_prefix_colors, ), ] sections = [ ("network", network_options), ("look", look_options), ("color", color_options), ] super().__init__(sections) # The server section is essentially a section with subsections and no # options, handle that case independently. W.config_new_section( self._ptr, "server", 0, 0, "matrix_config_server_read_cb", "", "matrix_config_server_write_cb", "", "", "", "", "", "", "", ) def read(self): super().read() self.human_buffer_names = self.look.human_buffer_names def free(self): section_ptr = W.config_search_section(self._ptr, "server") W.config_section_free(section_ptr) super().free() weechat-matrix-0.3.0/matrix/globals.py000066400000000000000000000031771407004440000177360ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Copyright © 2018, 2019 Damir Jelić # # Permission to use, copy, modify, and/or distribute this software for # any purpose with or without fee is hereby granted, provided that the # above copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. from __future__ import unicode_literals import sys from typing import Any, Dict, Optional from logbook import Logger from collections import OrderedDict from .utf import WeechatWrapper if False: from .server import MatrixServer from .config import MatrixConfig from .uploads import Upload try: import weechat W = weechat if sys.hexversion >= 0x3000000 else WeechatWrapper(weechat) except ImportError: import matrix._weechat as weechat # type: ignore W = weechat SERVERS = dict() # type: Dict[str, MatrixServer] CONFIG = None # type: Any ENCRYPTION = True # type: bool SCRIPT_NAME = "matrix" # type: str BUFFER_NAME_PREFIX = "{}.".format(SCRIPT_NAME) # type: str TYPING_NOTICE_TIMEOUT = 4000 # 4 seconds typing notice lifetime LOGGER = Logger("weechat-matrix") UPLOADS = OrderedDict() # type: Dict[str, Upload] weechat-matrix-0.3.0/matrix/message_renderer.py000066400000000000000000000100461407004440000216160ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Copyright © 2018, 2019 Damir Jelić # # Permission to use, copy, modify, and/or distribute this software for # any purpose with or without fee is hereby granted, provided that the # above copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. """Module for rendering matrix messages in Weechat.""" from __future__ import unicode_literals from nio import Api from .globals import W from .colors import Formatted class Render(object): """Class collecting methods for rendering matrix messages in Weechat.""" @staticmethod def _media(url, description): return ("{del_color}<{ncolor}{desc}{del_color}>{ncolor} " "{del_color}[{ncolor}{url}{del_color}]{ncolor}").format( del_color=W.color("chat_delimiters"), ncolor=W.color("reset"), desc=description, url=url) @staticmethod def media(mxc, body, homeserver=None): """Render a mxc media URI.""" url = Api.mxc_to_http(mxc, homeserver) description = "{}".format(body) if body else "file" return Render._media(url, description) @staticmethod def encrypted_media(mxc, body, key, hash, iv, homeserver=None): """Render a mxc media URI of an encrypted file.""" http_url = Api.encrypted_mxc_to_plumb( mxc, key, hash, iv, homeserver ) url = http_url if http_url else mxc description = "{}".format(body) if body else "file" return Render._media(url, description) @staticmethod def message(body, formatted_body): """Render a room message.""" if formatted_body: formatted = Formatted.from_html(formatted_body) return formatted.to_weechat() return body @staticmethod def redacted(censor, reason=None): """Render a redacted event message.""" reason = ( ', reason: "{reason}"'.format(reason=reason) if reason else "" ) data = ( "{del_color}<{log_color}Message redacted by: " "{censor}{log_color}{reason}{del_color}>{ncolor}" ).format( del_color=W.color("chat_delimiters"), ncolor=W.color("reset"), log_color=W.color("logger.color.backlog_line"), censor=censor, reason=reason, ) return data @staticmethod def room_encryption(nick): """Render a room encryption event.""" return "{nick} has enabled encryption in this room".format(nick=nick) @staticmethod def unknown(message_type, content=None): """Render a message of an unknown type.""" content = ( ': "{content}"'.format(content=content) if content else "" ) return "Unknown message of type {t}{c}".format( t=message_type, c=content ) @staticmethod def megolm(): """Render an undecrypted megolm event.""" return ("{del_color}<{log_color}Unable to decrypt: " "The sender's device has not sent us " "the keys for this message{del_color}>{ncolor}").format( del_color=W.color("chat_delimiters"), log_color=W.color("logger.color.backlog_line"), ncolor=W.color("reset")) @staticmethod def bad(event): """Render a malformed event of a known type""" return "Bad event received, event type: {t}".format(t=event.type) weechat-matrix-0.3.0/matrix/server.py000066400000000000000000002040261407004440000176150ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Copyright © 2018, 2019 Damir Jelić # # Permission to use, copy, modify, and/or distribute this software for # any purpose with or without fee is hereby granted, provided that the # above copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. from __future__ import unicode_literals import os import pprint import socket import ssl import time import copy from collections import defaultdict, deque from atomicwrites import atomic_write from typing import ( Any, Deque, Dict, Optional, List, NamedTuple, DefaultDict, Type, Union, ) from uuid import UUID from nio import ( Api, HttpClient, ClientConfig, LocalProtocolError, LoginResponse, LoginInfoResponse, Response, Rooms, RoomSendResponse, RoomSendError, SyncResponse, ShareGroupSessionResponse, ShareGroupSessionError, KeysQueryResponse, KeysClaimResponse, DevicesResponse, UpdateDeviceResponse, DeleteDevicesAuthResponse, DeleteDevicesResponse, TransportType, RoomMessagesResponse, RoomMessagesError, EncryptionError, GroupEncryptionError, OlmTrustError, ErrorResponse, SyncError, LoginError, JoinedMembersResponse, JoinedMembersError, RoomKeyEvent, KeyVerificationStart, KeyVerificationCancel, KeyVerificationKey, KeyVerificationMac, KeyVerificationEvent, ToDeviceMessage, ToDeviceResponse, ToDeviceError ) from . import globals as G from .buffer import OwnAction, OwnMessage, RoomBuffer from .config import ConfigSection, Option, ServerBufferType from .globals import SCRIPT_NAME, SERVERS, W, TYPING_NOTICE_TIMEOUT from .utf import utf8_decode from .utils import create_server_buffer, key_from_value, server_buffer_prnt from .uploads import Upload from .colors import Formatted, FormattedString, DEFAULT_ATTRIBUTES try: from urllib.parse import urlparse except ImportError: from urlparse import urlparse # type: ignore try: FileNotFoundError # type: ignore except NameError: FileNotFoundError = IOError EncryptionQueueItem = NamedTuple( "EncryptionQueueItem", [ ("message_type", str), ("message", Union[Formatted, Upload]), ], ) class ServerConfig(ConfigSection): def __init__(self, server_name, config_ptr): # type: (str, str) -> None self._server_name = server_name self._config_ptr = config_ptr self._option_ptrs = {} # type: Dict[str, str] options = [ Option( "autoconnect", "boolean", "", 0, 0, "off", ( "automatically connect to the matrix server when weechat " "is starting" ), ), Option( "address", "string", "", 0, 0, "", ( "Hostname or address of the server (note: content is " "evaluated, see /help eval)" ) ), Option( "port", "integer", "", 0, 65535, "443", "Port for the server" ), Option( "proxy", "string", "", 0, 0, "", ("Name of weechat proxy to use (see /help proxy) (note: " "content is evaluated, see /help eval)"), ), Option( "ssl_verify", "boolean", "", 0, 0, "on", ("Check that the SSL connection is fully trusted"), ), Option( "username", "string", "", 0, 0, "", ( "Username to use on the server (note: content is " "evaluated, see /help eval)" ) ), Option( "password", "string", "", 0, 0, "", ( "Password for the server (note: content is evaluated, see " "/help eval)" ), ), Option( "device_name", "string", "", 0, 0, "Weechat Matrix", ( "Device name to use when logging in, this " "is only used on the firt login. Afther that the /devices " "command can be used to change the device name. (note: " "content is evaluated, see /help eval)" ) ), Option( "autoreconnect_delay", "integer", "", 0, 86400, "10", ("Delay (in seconds) before trying to reconnect to server"), ), Option( "sso_helper_listening_port", "integer", "", 0, 65535, "0", ("The port that the SSO helpers web server should listen on"), ), ] section = W.config_search_section(config_ptr, "server") self._ptr = section for option in options: option_name = "{server}.{option}".format( server=self._server_name, option=option.name ) self._option_ptrs[option.name] = W.config_new_option( config_ptr, section, option_name, option.type, option.description, option.string_values, option.min, option.max, option.value, option.value, 0, "", "", "matrix_config_server_change_cb", self._server_name, "", "", ) autoconnect = ConfigSection.option_property("autoconnect", "boolean") address = ConfigSection.option_property("address", "string", evaluate=True) port = ConfigSection.option_property("port", "integer") proxy = ConfigSection.option_property("proxy", "string", evaluate=True) ssl_verify = ConfigSection.option_property("ssl_verify", "boolean") username = ConfigSection.option_property("username", "string", evaluate=True) device_name = ConfigSection.option_property("device_name", "string", evaluate=True) reconnect_delay = ConfigSection.option_property("autoreconnect_delay", "integer") password = ConfigSection.option_property( "password", "string", evaluate=True ) sso_helper_listening_port = ConfigSection.option_property( "sso_helper_listening_port", "integer" ) def free(self): W.config_section_free_options(self._ptr) class MatrixServer(object): # pylint: disable=too-many-instance-attributes def __init__(self, name, config_ptr): # type: (str, str) -> None # yapf: disable self.name = name # type: str self.user_id = "" self.device_id = "" # type: str self.room_buffers = dict() # type: Dict[str, RoomBuffer] self.buffers = dict() # type: Dict[str, str] self.server_buffer = None # type: Optional[str] self.fd_hook = None # type: Optional[str] self.ssl_hook = None # type: Optional[str] self.timer_hook = None # type: Optional[str] self.numeric_address = "" # type: Optional[str] self._connected = False # type: bool self.connecting = False # type: bool self.reconnect_delay = 0 # type: int self.reconnect_time = None # type: Optional[float] self.sync_time = None # type: Optional[float] self.socket = None # type: Optional[ssl.SSLSocket] self.ssl_context = ssl.create_default_context() # type: ssl.SSLContext self.transport_type = None # type: Optional[TransportType] self.sso_hook = None # Enable http2 negotiation on the ssl context. self.ssl_context.set_alpn_protocols(["h2", "http/1.1"]) try: self.ssl_context.set_npn_protocols(["h2", "http/1.1"]) except NotImplementedError: pass self.address = None self.homeserver = None self.client = None # type: Optional[HttpClient] self.access_token = None # type: Optional[str] self.next_batch = None # type: Optional[str] self.transaction_id = 0 # type: int self.lag = 0 # type: int self.lag_done = False # type: bool self.busy = False # type: bool self.first_sync = True self.send_fd_hook = None # type: Optional[str] self.send_buffer = b"" # type: bytes self.device_check_timestamp = None # type: Optional[int] self.device_deletion_queue = dict() # type: Dict[str, str] self.encryption_queue = defaultdict(deque) \ # type: DefaultDict[str, Deque[EncryptionQueueItem]] self.backlog_queue = dict() # type: Dict[str, str] self.user_gc_time = time.time() # type: float self.member_request_list = [] # type: List[str] self.rooms_with_missing_members = [] # type: List[str] self.lazy_load_hook = None # type: Optional[str] # These flags remember if we made some requests so that we don't # make them again while we wait on a response, the flags need to be # cleared when we disconnect. self.keys_queried = False # type: bool self.keys_claimed = defaultdict(bool) # type: Dict[str, bool] self.group_session_shared = defaultdict(bool) # type: Dict[str, bool] self.ignore_while_sharing = defaultdict(bool) # type: Dict[str, bool] self.to_device_sent = [] # type: List[ToDeviceMessage] # Try to load the device id, the device id is loaded every time the # user changes but some login flows don't use a user so try to load the # device for a main user. self._load_device_id("main") self.config = ServerConfig(self.name, config_ptr) self._create_session_dir() # yapf: enable def _create_session_dir(self): path = os.path.join("matrix", self.name) if not W.mkdir_home(path, 0o700): message = ( "{prefix}matrix: Error creating server session " "directory" ).format(prefix=W.prefix("error")) W.prnt("", message) @property def connected(self): return self._connected @connected.setter def connected(self, value): self._connected = value W.bar_item_update("buffer_modes") W.bar_item_update("matrix_modes") def get_session_path(self): home_dir = W.info_get("weechat_dir", "") return os.path.join(home_dir, "matrix", self.name) def _load_device_id(self, user=None): user = user or self.config.username file_name = "{}{}".format(user, ".device_id") path = os.path.join(self.get_session_path(), file_name) if not os.path.isfile(path): return with open(path, "r") as device_file: device_id = device_file.readline().rstrip() if device_id: self.device_id = device_id def save_device_id(self): file_name = "{}{}".format(self.config.username or "main", ".device_id") path = os.path.join(self.get_session_path(), file_name) with atomic_write(path, overwrite=True) as device_file: device_file.write(self.device_id) @staticmethod def _parse_url(address, port): if not address.startswith("http"): address = "https://{}".format(address) parsed_url = urlparse(address) homeserver = parsed_url._replace( netloc=parsed_url.hostname + ":{}".format(port) ) return homeserver def _change_client(self): homeserver = MatrixServer._parse_url( self.config.address, self.config.port ) self.address = homeserver.hostname self.homeserver = homeserver config = ClientConfig(store_sync_tokens=True) self.client = HttpClient( homeserver.geturl(), self.config.username, self.device_id, self.get_session_path(), config=config ) self.client.add_to_device_callback( self.key_verification_cb, KeyVerificationEvent ) def key_verification_cb(self, event): if isinstance(event, KeyVerificationStart): self.info_highlight("{user} via {device} has started a key " "verification process.\n" "To accept use /olm verification " "accept {user} {device}".format( user=event.sender, device=event.from_device )) elif isinstance(event, KeyVerificationKey): sas = self.client.key_verifications.get(event.transaction_id, None) if not sas: return if sas.canceled: return device = sas.other_olm_device emoji = sas.get_emoji() emojis = [x[0] for x in emoji] descriptions = [x[1] for x in emoji] centered_width = 12 def center_emoji(emoji, width): # Assume each emoji has width 2 emoji_width = 2 # These are emojis that need VARIATION-SELECTOR-16 (U+FE0F) so # that they are rendered with coloured glyphs. For these, we # need to add an extra space after them so that they are # rendered properly in weechat. variation_selector_emojis = [ '☁️', '❤️', '☂️', '✏️', '✂️', '☎️', '✈️' ] # Hack to make weechat behave properly when one of the above is # printed. if emoji in variation_selector_emojis: emoji += " " # This is a trick to account for the fact that emojis are wider # than other monospace characters. placeholder = '.' * emoji_width return placeholder.center(width).replace(placeholder, emoji) emoji_str = u"".join(center_emoji(e, centered_width) for e in emojis) desc = u"".join(d.center(centered_width) for d in descriptions) short_string = u"\n".join([emoji_str, desc]) self.info_highlight(u"Short authentication string for " u"{user} via {device}:\n{string}\n" u"Confirm that the strings match with " u"/olm verification confirm {user} " u"{device}".format( user=device.user_id, device=device.id, string=short_string )) elif isinstance(event, KeyVerificationMac): try: sas = self.client.key_verifications[event.transaction_id] except KeyError: return device = sas.other_olm_device if sas.verified: self.info_highlight("Device {} of user {} successfully " "verified".format( device.id, device.user_id )) elif isinstance(event, KeyVerificationCancel): self.info_highlight("The interactive device verification with " "user {} got canceled: {}.".format( event.sender, event.reason )) def update_option(self, option, option_name): if option_name == "address": self._change_client() elif option_name == "port": self._change_client() elif option_name == "ssl_verify": value = W.config_boolean(option) if value: self.ssl_context.verify_mode = ssl.CERT_REQUIRED self.ssl_context.check_hostname = True else: self.ssl_context.check_hostname = False self.ssl_context.verify_mode = ssl.CERT_NONE elif option_name == "username": value = W.config_string(option) self.access_token = "" self._load_device_id() if self.client: self.client.user = value if self.device_id: self.client.device_id = self.device_id else: pass def send_or_queue(self, request): # type: (bytes) -> None self.send(request) def try_send(self, message): # type: (MatrixServer, bytes) -> bool sock = self.socket if not sock: return False total_sent = 0 message_length = len(message) while total_sent < message_length: try: sent = sock.send(message[total_sent:]) except ssl.SSLWantWriteError: hook = W.hook_fd(sock.fileno(), 0, 1, 0, "send_cb", self.name) self.send_fd_hook = hook self.send_buffer = message[total_sent:] return True except socket.error as error: self._abort_send() errno = "error" + str(error.errno) + " " if error.errno else "" strerr = error.strerror if error.strerror else "Unknown reason" strerr = errno + strerr error_message = ( "{prefix}Error while writing to " "socket: {error}" ).format(prefix=W.prefix("network"), error=strerr) server_buffer_prnt(self, error_message) server_buffer_prnt( self, ("{prefix}matrix: disconnecting from server...").format( prefix=W.prefix("network") ), ) self.disconnect() return False if sent == 0: self._abort_send() server_buffer_prnt( self, "{prefix}matrix: Error while writing to socket".format( prefix=W.prefix("network") ), ) server_buffer_prnt( self, ("{prefix}matrix: disconnecting from server...").format( prefix=W.prefix("network") ), ) self.disconnect() return False total_sent = total_sent + sent self._finalize_send() return True def _abort_send(self): self.send_buffer = b"" def _finalize_send(self): # type: (MatrixServer) -> None self.send_buffer = b"" def info_highlight(self, message): buf = "" if self.server_buffer: buf = self.server_buffer msg = "{}{}: {}".format(W.prefix("network"), SCRIPT_NAME, message) W.prnt_date_tags(buf, 0, "notify_highlight", msg) def info(self, message): buf = "" if self.server_buffer: buf = self.server_buffer msg = "{}{}: {}".format(W.prefix("network"), SCRIPT_NAME, message) W.prnt(buf, msg) def error(self, message): buf = "" if self.server_buffer: buf = self.server_buffer msg = "{}{}: {}".format(W.prefix("error"), SCRIPT_NAME, message) W.prnt(buf, msg) def send(self, data): # type: (bytes) -> bool self.try_send(data) return True def reconnect(self): message = ("{prefix}matrix: reconnecting to server...").format( prefix=W.prefix("network") ) server_buffer_prnt(self, message) self.reconnect_time = None if not self.connect(): self.schedule_reconnect() def schedule_reconnect(self): # type: (MatrixServer) -> None self.connecting = True self.reconnect_time = time.time() if self.reconnect_delay: self.reconnect_delay = ( self.reconnect_delay * G.CONFIG.network.autoreconnect_delay_growing ) else: self.reconnect_delay = self.config.reconnect_delay if G.CONFIG.network.autoreconnect_delay_max > 0: self.reconnect_delay = min(self.reconnect_delay, G.CONFIG.network.autoreconnect_delay_max) message = ( "{prefix}matrix: reconnecting to server in {t} " "seconds" ).format(prefix=W.prefix("network"), t=self.reconnect_delay) server_buffer_prnt(self, message) def _close_socket(self): # type: () -> None if self.socket: try: self.socket.shutdown(socket.SHUT_RDWR) except socket.error: pass try: self.socket.close() except OSError: pass def disconnect(self, reconnect=True): # type: (bool) -> None if self.fd_hook: W.unhook(self.fd_hook) self._close_socket() self.fd_hook = None self.socket = None self.connected = False self.access_token = "" self.send_buffer = b"" self.transport_type = None self.member_request_list = [] if self.client: try: self.client.disconnect() except LocalProtocolError: pass self.lag = 0 W.bar_item_update("lag") self.reconnect_time = None # Clear our request flags. self.keys_queried = False self.keys_claimed = defaultdict(bool) self.group_session_shared = defaultdict(bool) self.ignore_while_sharing = defaultdict(bool) self.to_device_sent = [] if self.server_buffer: message = ("{prefix}matrix: disconnected from server").format( prefix=W.prefix("network") ) server_buffer_prnt(self, message) if reconnect: self.schedule_reconnect() else: self.reconnect_delay = 0 def connect(self): # type: (MatrixServer) -> int if not self.config.address or not self.config.port: message = "{prefix}Server address or port not set".format( prefix=W.prefix("error") ) W.prnt("", message) return False if self.connected: return True if not self.server_buffer: create_server_buffer(self) if not self.timer_hook: self.timer_hook = W.hook_timer( 1 * 1000, 0, 0, "matrix_timer_cb", self.name ) ssl_message = " (SSL)" if self.ssl_context.check_hostname else "" message = ( "{prefix}matrix: Connecting to " "{server}:{port}{ssl}..." ).format( prefix=W.prefix("network"), server=self.address, port=self.config.port, ssl=ssl_message, ) W.prnt(self.server_buffer, message) W.hook_connect( self.config.proxy, self.address, self.config.port, 1, 0, "", "connect_cb", self.name, ) return True def schedule_sync(self): self.sync_time = time.time() def sync(self, timeout=None, sync_filter=None): # type: (Optional[int], Optional[Dict[Any, Any]]) -> None if not self.client: return self.sync_time = None _, request = self.client.sync(timeout, sync_filter, full_state=self.first_sync) self.send_or_queue(request) def login_info(self): # type: () -> None if not self.client: return if self.client.logged_in: self.login() return _, request = self.client.login_info() self.send(request) """Start a local HTTP server to listen for SSO tokens.""" def start_login_sso(self): # type: () -> None if self.sso_hook: # If there is a stale SSO process hanging around kill it. We could # let it stay around but the URL that needs to be opened by the # user is printed out in the callback. W.hook_set(self.sso_hook, "signal", "term") self.sso_hook = None process_args = { "buffer_flush": "1", "arg1": "--port", "arg2": str(self.config.sso_helper_listening_port) } self.sso_hook = W.hook_process_hashtable( "matrix_sso_helper", process_args, 0, "sso_login_cb", self.name ) def login(self, token=None): # type: (...) -> None assert self.client is not None if self.client.logged_in: msg = ( "{prefix}{script_name}: Already logged in, " "syncing..." ).format(prefix=W.prefix("network"), script_name=SCRIPT_NAME) W.prnt(self.server_buffer, msg) timeout = 0 if self.transport_type == TransportType.HTTP else 30000 limit = (G.CONFIG.network.max_initial_sync_events if self.first_sync else 500) sync_filter = { "room": { "timeline": {"limit": limit}, "state": {"lazy_load_members": True} } } self.sync(timeout, sync_filter) return if (not self.config.username or not self.config.password) and not token: message = "{prefix}User or password not set".format( prefix=W.prefix("error") ) W.prnt("", message) return self.disconnect() if token: _, request = self.client.login( device_name=self.config.device_name, token=token ) else: _, request = self.client.login( password=self.config.password, device_name=self.config.device_name ) self.send_or_queue(request) msg = "{prefix}matrix: Logging in...".format( prefix=W.prefix("network") ) W.prnt(self.server_buffer, msg) def devices(self): _, request = self.client.devices() self.send_or_queue(request) def delete_device(self, device_id, auth=None): uuid, request = self.client.delete_devices([device_id], auth) self.device_deletion_queue[uuid] = device_id self.send_or_queue(request) return def rename_device(self, device_id, display_name): content = { "display_name": display_name } _, request = self.client.update_device(device_id, content) self.send_or_queue(request) def room_send_state(self, room_buffer, body, event_type): _, request = self.client.room_put_state( room_buffer.room.room_id, event_type, body ) self.send_or_queue(request) def room_send_redaction(self, room_buffer, event_id, reason=None): _, request = self.client.room_redact( room_buffer.room.room_id, event_id, reason ) self.send_or_queue(request) def room_kick(self, room_buffer, user_id, reason=None): _, request = self.client.room_kick( room_buffer.room.room_id, user_id, reason ) self.send_or_queue(request) def room_invite(self, room_buffer, user_id): _, request = self.client.room_invite(room_buffer.room.room_id, user_id) self.send_or_queue(request) def room_join(self, room_id): _, request = self.client.join(room_id) self.send_or_queue(request) def room_leave(self, room_id): _, request = self.client.room_leave(room_id) self.send_or_queue(request) def room_get_messages(self, room_id): if not self.connected or not self.client.logged_in: return False room_buffer = self.find_room_from_id(room_id) # We're already fetching old messages if room_buffer.backlog_pending: return False if not room_buffer.prev_batch: return False uuid, request = self.client.room_messages( room_id, room_buffer.prev_batch, limit=10) room_buffer.backlog_pending = True self.backlog_queue[uuid] = room_id self.send_or_queue(request) return True def room_send_read_marker(self, room_id, event_id): """Send read markers for the provided room. Args: room_id(str): the room for which the read markers should be sent. event_id(str): the event id where to set the marker """ if not self.connected or not self.client.logged_in: return _, request = self.client.room_read_markers( room_id, fully_read_event=event_id, read_event=event_id) self.send(request) def room_send_typing_notice(self, room_buffer): """Send a typing notice for the provided room. Args: room_buffer(RoomBuffer): the room for which the typing notice needs to be sent. """ if not self.connected or not self.client.logged_in: return input = room_buffer.weechat_buffer.input typing_enabled = bool(int(W.string_eval_expression( G.CONFIG.network.typing_notice_conditions, {}, {"typing_enabled": str(int(room_buffer.typing_enabled))}, {"type": "condition"} ))) if not typing_enabled: return # Don't send a typing notice if the user is typing in a weechat command if input.startswith("/") and not input.startswith("//"): return # Don't send a typing notice if we only typed a couple of letters. elif len(input) < 4 and not room_buffer.typing: return # If we were typing already and our input bar now has no letters or # only a couple of letters stop the typing notice. elif len(input) < 4: _, request = self.client.room_typing( room_buffer.room.room_id, typing_state=False) room_buffer.typing = False self.send(request) return # Don't send out a typing notice if we already sent one out and it # didn't expire yet. if not room_buffer.typing_notice_expired: return _, request = self.client.room_typing( room_buffer.room.room_id, typing_state=True, timeout=TYPING_NOTICE_TIMEOUT) room_buffer.typing = True self.send(request) def room_send_upload( self, upload ): """Send a room message containing the mxc URI of an upload.""" try: room_buffer = self.find_room_from_id(upload.room_id) except (ValueError, KeyError): return True assert self.client if room_buffer.room.encrypted: assert upload.encrypt content = upload.content try: uuid = self.room_send_event(upload.room_id, content) except (EncryptionError, GroupEncryptionError): message = EncryptionQueueItem(upload.msgtype, upload) self.encryption_queue[upload.room_id].append(message) return False attributes = DEFAULT_ATTRIBUTES.copy() formatted = Formatted([FormattedString( upload.render, attributes )]) own_message = OwnMessage( self.user_id, 0, "", uuid, upload.room_id, formatted ) room_buffer.sent_messages_queue[uuid] = own_message self.print_unconfirmed_message(room_buffer, own_message) return True def share_group_session( self, room_id, ignore_missing_sessions=False, ignore_unverified_devices=False ): self.ignore_while_sharing[room_id] = ignore_unverified_devices _, request = self.client.share_group_session( room_id, ignore_missing_sessions=ignore_missing_sessions, ignore_unverified_devices=ignore_unverified_devices ) self.send(request) self.group_session_shared[room_id] = True def room_send_event( self, room_id, # type: str content, # type: Dict[str, str] event_type="m.room.message", # type: str ignore_unverified_devices=False, # type: bool ): # type: (...) -> UUID assert self.client try: uuid, request = self.client.room_send( room_id, event_type, content ) self.send(request) return uuid except GroupEncryptionError: try: if not self.group_session_shared[room_id]: self.share_group_session( room_id, ignore_unverified_devices=ignore_unverified_devices ) raise except EncryptionError: if not self.keys_claimed[room_id]: _, request = self.client.keys_claim(room_id) self.keys_claimed[room_id] = True self.send(request) raise def room_send_message( self, room_buffer, # type: RoomBuffer formatted, # type: Formatted msgtype="m.text", # type: str ignore_unverified_devices=False, # type: bool in_reply_to_event_id="", # type: str ): # type: (...) -> bool room = room_buffer.room assert self.client content = {"msgtype": msgtype, "body": formatted.to_plain()} if formatted.is_formatted() or in_reply_to_event_id: content["format"] = "org.matrix.custom.html" content["formatted_body"] = formatted.to_html() if in_reply_to_event_id: content["m.relates_to"] = { "m.in_reply_to": {"event_id": in_reply_to_event_id} } try: uuid = self.room_send_event( room.room_id, content, ignore_unverified_devices=ignore_unverified_devices ) except (EncryptionError, GroupEncryptionError): message = EncryptionQueueItem(msgtype, formatted) self.encryption_queue[room.room_id].append(message) return False if msgtype == "m.emote": message_class = OwnAction # type: Type else: message_class = OwnMessage own_message = message_class( self.user_id, 0, "", uuid, room.room_id, formatted ) room_buffer.sent_messages_queue[uuid] = own_message self.print_unconfirmed_message(room_buffer, own_message) return True def print_unconfirmed_message(self, room_buffer, message): """Print an outgoing message before getting a receive confirmation. The message is printed out greyed out and only printed out if the client is configured to do so. The message needs to be later modified to contain proper coloring, this is done in the replace_printed_line_by_uuid() method of the RoomBuffer class. Args: room_buffer(RoomBuffer): the buffer of the room where the message needs to be printed out message(OwnMessages): the message that should be printed out """ if G.CONFIG.network.print_unconfirmed_messages: room_buffer.printed_before_ack_queue.append(message.uuid) plain_message = message.formatted_message.to_weechat() plain_message = W.string_remove_color(plain_message, "") attributes = DEFAULT_ATTRIBUTES.copy() attributes["fgcolor"] = G.CONFIG.color.unconfirmed_message_fg attributes["bgcolor"] = G.CONFIG.color.unconfirmed_message_bg new_formatted = Formatted([FormattedString( plain_message, attributes )]) new_message = copy.copy(message) new_message.formatted_message = new_formatted if isinstance(new_message, OwnAction): room_buffer.self_action(new_message) elif isinstance(new_message, OwnMessage): room_buffer.self_message(new_message) def keys_upload(self): _, request = self.client.keys_upload() self.send_or_queue(request) def keys_query(self): _, request = self.client.keys_query() self.keys_queried = True self.send_or_queue(request) def get_joined_members(self, room_id): if not self.connected or not self.client.logged_in: return if room_id in self.member_request_list: return self.member_request_list.append(room_id) _, request = self.client.joined_members(room_id) self.send(request) def _print_message_error(self, message): server_buffer_prnt( self, ( "{prefix}Unhandled {status_code} error, please " "inform the developers about this." ).format( prefix=W.prefix("error"), status_code=message.response.status ), ) server_buffer_prnt(self, pprint.pformat(message.__class__.__name__)) server_buffer_prnt(self, pprint.pformat(message.request.payload)) server_buffer_prnt(self, pprint.pformat(message.response.body)) def handle_own_messages_error(self, response): room_buffer = self.room_buffers[response.room_id] if response.uuid not in room_buffer.printed_before_ack_queue: return message = room_buffer.sent_messages_queue.pop(response.uuid) room_buffer.mark_message_as_unsent(response.uuid, message) room_buffer.printed_before_ack_queue.remove(response.uuid) def handle_own_messages(self, response): def send_marker(): if not room_buffer.read_markers_enabled: return self.room_send_read_marker(response.room_id, response.event_id) room_buffer.last_read_event = response.event_id room_buffer = self.room_buffers[response.room_id] message = room_buffer.sent_messages_queue.pop(response.uuid, None) # The message might have been returned in a sync response before we got # a room send response. if not message: return message.event_id = response.event_id # We already printed the message, just modify it to contain the proper # colors and formatting. if response.uuid in room_buffer.printed_before_ack_queue: room_buffer.replace_printed_line_by_uuid(response.uuid, message) room_buffer.printed_before_ack_queue.remove(response.uuid) send_marker() return if isinstance(message, OwnAction): room_buffer.self_action(message) send_marker() return if isinstance(message, OwnMessage): room_buffer.self_message(message) send_marker() return raise NotImplementedError( "Unsupported message of type {}".format(type(message)) ) def handle_backlog_response(self, response): room_id = self.backlog_queue.pop(response.uuid) room_buffer = self.find_room_from_id(room_id) room_buffer.first_view = False room_buffer.handle_backlog(response) def handle_devices_response(self, response): if not response.devices: m = "{}{}: No devices found for this account".format( W.prefix("error"), SCRIPT_NAME) W.prnt(self.server_buffer, m) header = (W.prefix("network") + SCRIPT_NAME + ": Devices for " "server {}{}{}:\n" " Device ID Device Name " "Last Seen").format( W.color("chat_server"), self.name, W.color("reset") ) W.prnt(self.server_buffer, header) lines = [] for device in response.devices: last_seen_date = ("?" if not device.last_seen_date else device.last_seen_date.strftime("%Y/%m/%d %H:%M")) last_seen = "{ip} @ {date}".format( ip=device.last_seen_ip or "?", date=last_seen_date ) device_color = ("chat_self" if device.id == self.device_id else W.info_get("nick_color_name", device.id)) bold = W.color("bold") if device.id == self.device_id else "" line = " {}{}{:<18}{}{:<34}{:<}".format( bold, W.color(device_color), device.id, W.color("resetcolor"), device.display_name or "", last_seen ) lines.append(line) W.prnt(self.server_buffer, "\n".join(lines)) """Handle a login info response and chose one of the available flows This currently supports only SSO and password logins. If both are available password takes precedence over SSO if a username and password is provided. """ def _handle_login_info(self, response): if ("m.login.sso" in response.flows and (not self.config.username or not self.config.password)): self.start_login_sso() elif "m.login.password" in response.flows: self.login() else: self.error("No supported login flow found") self.disconnect() def _handle_login(self, response): self.access_token = response.access_token self.user_id = response.user_id self.client.access_token = response.access_token self.device_id = response.device_id self.save_device_id() message = "{prefix}matrix: Logged in as {user}".format( prefix=W.prefix("network"), user=self.user_id ) W.prnt(self.server_buffer, message) if not self.client.olm_account_shared: self.keys_upload() sync_filter = { "room": { "timeline": { "limit": G.CONFIG.network.max_initial_sync_events }, "state": {"lazy_load_members": True} } } self.sync(timeout=0, sync_filter=sync_filter) def _handle_room_info(self, response): for room_id, info in response.rooms.invite.items(): room = self.client.invited_rooms.get(room_id, None) if room: if room.inviter: inviter_msg = " by {}{}".format( W.color("chat_nick_other"), room.inviter ) else: inviter_msg = "" self.info_highlight( "You have been invited to {} {}({}{}{}){}" "{}".format( room.display_name, W.color("chat_delimiters"), W.color("chat_channel"), room_id, W.color("chat_delimiters"), W.color("reset"), inviter_msg, ) ) else: self.info_highlight("You have been invited to {}.".format( room_id )) for room_id, info in response.rooms.leave.items(): if room_id not in self.buffers: continue room_buffer = self.find_room_from_id(room_id) room_buffer.handle_left_room(info) for room_id, info in response.rooms.join.items(): if room_id not in self.buffers: self.create_room_buffer(room_id, info.timeline.prev_batch) room_buffer = self.find_room_from_id(room_id) room_buffer.handle_joined_room(info) def add_unhandled_users(self, rooms, n): # type: (List[RoomBuffer], int) -> bool total_users = 0 while total_users <= n: try: room_buffer = rooms.pop() except IndexError: return False handled_users = 0 users = room_buffer.unhandled_users for user_id in users: room_buffer.add_user(user_id, 0, True) handled_users += 1 total_users += 1 if total_users >= n: room_buffer.unhandled_users = users[handled_users:] rooms.append(room_buffer) return True room_buffer.unhandled_users = [] return False def _hook_lazy_user_adding(self): if not self.lazy_load_hook: hook = W.hook_timer(1 * 1000, 0, 0, "matrix_load_users_cb", self.name) self.lazy_load_hook = hook def decrypt_printed_messages(self, key_event): """Decrypt already printed messages and send them to the buffer""" try: room_buffer = self.find_room_from_id(key_event.room_id) except KeyError: return decrypted_events = [] for undecrypted_event in room_buffer.undecrypted_events: if undecrypted_event.session_id != key_event.session_id: continue event = self.client.decrypt_event(undecrypted_event) if event: decrypted_events.append((undecrypted_event, event)) for event_pair in decrypted_events: undecrypted_event, event = event_pair room_buffer.undecrypted_events.remove(undecrypted_event) room_buffer.replace_undecrypted_line(event) def start_verification(self, device): _, request = self.client.start_key_verification(device) self.send(request) self.info("Starting an interactive device verification with " "{} {}".format(device.user_id, device.id)) def accept_sas(self, sas): _, request = self.client.accept_key_verification(sas.transaction_id) self.send(request) def cancel_sas(self, sas): _, request = self.client.cancel_key_verification(sas.transaction_id) self.send(request) def to_device(self, message): _, request = self.client.to_device(message) self.send(request) def confirm_sas(self, sas): _, request = self.client.confirm_short_auth_string(sas.transaction_id) self.send(request) device = sas.other_olm_device if sas.verified: self.info("Device {} of user {} successfully verified".format( device.id, device.user_id )) else: self.info("Waiting for {} to confirm...".format(device.user_id)) def _handle_sync(self, response): # we got the same batch again, nothing to do self.first_sync = False if self.next_batch == response.next_batch: self.schedule_sync() return self._handle_room_info(response) for event in response.to_device_events: if isinstance(event, RoomKeyEvent): message = { "sender": event.sender, "sender_key": event.sender_key, "room_id": event.room_id, "session_id": event.session_id, "algorithm": event.algorithm, "server": self.name, } W.hook_hsignal_send("matrix_room_key_received", message) # TODO try to decrypt some cached undecrypted messages with the # new key # self.decrypt_printed_messages(event) if self.client.should_upload_keys: self.keys_upload() if self.client.should_query_keys and not self.keys_queried: self.keys_query() for room_buffer in self.room_buffers.values(): # It's our initial sync, we need to fetch room members, so add # the room to the missing members queue. # 3 reasons we fetch room members here: # * If the lazy load room users setting is off, otherwise we will # fetch them when we switch to the buffer # * If the room is encrypted, encryption needs the full member # list for it to work. # * If we are the only member, it is unlikely really an empty # room and since we don't want a bunch of "Empty room?" # buffers in our buffer list we fetch members here. if not self.next_batch: if (not G.CONFIG.network.lazy_load_room_users or room_buffer.room.encrypted or room_buffer.room.member_count <= 1): self.rooms_with_missing_members.append( room_buffer.room.room_id ) if room_buffer.unhandled_users: self._hook_lazy_user_adding() break self.next_batch = response.next_batch self.schedule_sync() W.bar_item_update("matrix_typing_notice") if self.rooms_with_missing_members: self.get_joined_members(self.rooms_with_missing_members.pop()) def handle_delete_device_auth(self, response): device_id = self.device_deletion_queue.pop(response.uuid, None) if not device_id: return for flow in response.flows: if "m.login.password" in flow["stages"]: session = response.session auth = { "type": "m.login.password", "session": session, "user": self.client.user_id, "password": self.config.password } self.delete_device(device_id, auth) return self.error("No supported auth method for device deletion found.") def handle_error_response(self, response): self.error("Error: {}".format(str(response))) if isinstance(response, (SyncError, LoginError)): self.disconnect() elif isinstance(response, JoinedMembersError): self.rooms_with_missing_members.append(response.room_id) self.get_joined_members(self.rooms_with_missing_members.pop()) elif isinstance(response, RoomSendError): self.handle_own_messages_error(response) elif isinstance(response, ShareGroupSessionError): self.group_session_shared[response.room_id] = False self.share_group_session( response.room_id, False, self.ignore_while_sharing[response.room_id] ) elif isinstance(response, ToDeviceError): try: self.to_device_sent.remove(response.to_device_message) except ValueError: pass def handle_response(self, response): # type: (Response) -> None response_lag = response.elapsed current_lag = 0 if self.client: current_lag = self.client.lag if response_lag >= current_lag: self.lag = response_lag * 1000 self.lag_done = True W.bar_item_update("lag") if isinstance(response, ErrorResponse): self.handle_error_response(response) if isinstance(response, RoomMessagesError): room_buffer = self.room_buffers[response.room_id] room_buffer.backlog_pending = False elif isinstance(response, ToDeviceResponse): try: self.to_device_sent.remove(response.to_device_message) except ValueError: pass elif isinstance(response, LoginResponse): self._handle_login(response) elif isinstance(response, LoginInfoResponse): self._handle_login_info(response) elif isinstance(response, SyncResponse): self._handle_sync(response) elif isinstance(response, RoomSendResponse): self.handle_own_messages(response) elif isinstance(response, RoomMessagesResponse): self.handle_backlog_response(response) elif isinstance(response, DevicesResponse): self.handle_devices_response(response) elif isinstance(response, UpdateDeviceResponse): self.info("Device name successfully updated") elif isinstance(response, DeleteDevicesAuthResponse): self.handle_delete_device_auth(response) elif isinstance(response, DeleteDevicesResponse): self.info("Device successfully deleted") elif isinstance(response, KeysQueryResponse): self.keys_queried = False W.bar_item_update("buffer_modes") W.bar_item_update("matrix_modes") for user_id, device_dict in response.changed.items(): for device in device_dict.values(): message = { "user_id": user_id, "device_id": device.id, "ed25519": device.ed25519, "curve25519": device.curve25519, "deleted": str(device.deleted) } W.hook_hsignal_send("matrix_device_changed", message) elif isinstance(response, JoinedMembersResponse): self.member_request_list.remove(response.room_id) room_buffer = self.room_buffers[response.room_id] users = [user.user_id for user in response.members] # Don't add the users directly use the lazy load hook. room_buffer.unhandled_users += users self._hook_lazy_user_adding() room_buffer.members_fetched = True room_buffer.update_buffer_name() # Fetch the users for the next room. if self.rooms_with_missing_members: self.get_joined_members(self.rooms_with_missing_members.pop()) # We are done adding all the users, do a full key query now since # the client knows all the encrypted room members. else: if self.client.should_query_keys and not self.keys_queried: self.keys_query() elif isinstance(response, KeysClaimResponse): self.keys_claimed[response.room_id] = False try: self.share_group_session( response.room_id, True, self.ignore_while_sharing[response.room_id] ) except OlmTrustError as e: m = ("Untrusted devices found in room: {}".format(e)) room_buffer = self.find_room_from_id(response.room_id) room_buffer.error(m) try: item = self.encryption_queue[response.room_id][0] if item.message_type not in ["m.file", "m.video", "m.audio", "m.image"]: room_buffer.last_message = item.message except IndexError: pass self.encryption_queue[response.room_id].clear() return elif isinstance(response, ShareGroupSessionResponse): room_id = response.room_id self.group_session_shared[response.room_id] = False ignore_unverified = self.ignore_while_sharing[response.room_id] self.ignore_while_sharing[response.room_id] = False room_buffer = self.room_buffers[room_id] while self.encryption_queue[room_id]: item = self.encryption_queue[room_id].popleft() try: if item.message_type in [ "m.file", "m.video", "m.audio", "m.image" ]: ret = self.room_send_upload(item.message) else: assert isinstance(item.message, Formatted) ret = self.room_send_message( room_buffer, item.message, item.message_type, ignore_unverified_devices=ignore_unverified ) if not ret: self.encryption_queue[room_id].pop() self.encryption_queue[room_id].appendleft(item) break except OlmTrustError: self.encryption_queue[room_id].clear() # If the item is a normal user message store it in the # buffer to enable the send-anyways functionality. if item.message_type not in ["m.file", "m.video", "m.audio", "m.image"]: room_buffer.last_message = item.message break def create_room_buffer(self, room_id, prev_batch): room = self.client.rooms[room_id] buf = RoomBuffer(room, self.name, self.homeserver, prev_batch) # We sadly don't get a correct summary on full_state from synapse so we # can't trust it that the members are fully synced # if room.members_synced: # buf.members_fetched = True self.room_buffers[room_id] = buf self.buffers[room_id] = buf.weechat_buffer._ptr def find_room_from_ptr(self, pointer): try: room_id = key_from_value(self.buffers, pointer) room_buffer = self.room_buffers[room_id] return room_buffer except (ValueError, KeyError): return None def find_room_from_id(self, room_id): room_buffer = self.room_buffers[room_id] return room_buffer def garbage_collect_users(self): """ Remove inactive users. This tries to keep the number of users added to the nicklist less than the configuration option matrix.network.max_nicklist_users. It removes users that have not been active for a day until there are less than max_nicklist_users or no users are left for removal. It never removes users that have a bigger power level than the default one. This function is run every hour by the server timer callback""" now = time.time() self.user_gc_time = now def day_passed(t1, t2): return (t2 - t1) > 86400 for room_buffer in self.room_buffers.values(): to_remove = max( (len(room_buffer.displayed_nicks) - G.CONFIG.network.max_nicklist_users), 0 ) if not to_remove: continue removed = 0 removed_user_ids = [] for user_id, nick in room_buffer.displayed_nicks.items(): user = room_buffer.weechat_buffer.users[nick] if (not user.speaking_time or day_passed(user.speaking_time, now)): room_buffer.weechat_buffer.part(nick, 0, False) removed_user_ids.append(user_id) removed += 1 if removed >= to_remove: break for user_id in removed_user_ids: del room_buffer.displayed_nicks[user_id] def buffer_merge(self): if not self.server_buffer: return buf = self.server_buffer if G.CONFIG.look.server_buffer == ServerBufferType.MERGE_CORE: num = W.buffer_get_integer(W.buffer_search_main(), "number") W.buffer_unmerge(buf, num + 1) W.buffer_merge(buf, W.buffer_search_main()) elif G.CONFIG.look.server_buffer == ServerBufferType.MERGE: if SERVERS: first = None for server in SERVERS.values(): if server.server_buffer: first = server.server_buffer break if first: num = W.buffer_get_integer( W.buffer_search_main(), "number" ) W.buffer_unmerge(buf, num + 1) if buf is not first: W.buffer_merge(buf, first) else: num = W.buffer_get_integer(W.buffer_search_main(), "number") W.buffer_unmerge(buf, num + 1) @utf8_decode def matrix_config_server_read_cb( data, config_file, section, option_name, value ): return_code = W.WEECHAT_CONFIG_OPTION_SET_ERROR if option_name: server_name, option = option_name.rsplit(".", 1) server = None if server_name in SERVERS: server = SERVERS[server_name] else: server = MatrixServer(server_name, config_file) SERVERS[server.name] = server # Ignore invalid options if option in server.config._option_ptrs: return_code = W.config_option_set( server.config._option_ptrs[option], value, 1 ) # TODO print out error message in case of erroneous return_code return return_code @utf8_decode def matrix_config_server_write_cb(data, config_file, section_name): if not W.config_write_line(config_file, section_name, ""): return W.WEECHAT_CONFIG_WRITE_ERROR for server in SERVERS.values(): for option in server.config._option_ptrs.values(): if not W.config_write_option(config_file, option): return W.WEECHAT_CONFIG_WRITE_ERROR return W.WEECHAT_CONFIG_WRITE_OK @utf8_decode def matrix_config_server_change_cb(server_name, option): # type: (str, str) -> int server = SERVERS[server_name] option_name = None # The function config_option_get_string() is used to get differing # properties from a config option, sadly it's only available in the plugin # API of weechat. option_name = key_from_value(server.config._option_ptrs, option) server.update_option(option, option_name) return 1 @utf8_decode def matrix_load_users_cb(server_name, remaining_calls): server = SERVERS[server_name] start = time.time() rooms = [x for x in server.room_buffers.values() if x.unhandled_users] while server.add_unhandled_users(rooms, 100): current = time.time() if current - start >= 0.1: return W.WEECHAT_RC_OK # We are done adding users, we can unhook now. W.unhook(server.lazy_load_hook) server.lazy_load_hook = None return W.WEECHAT_RC_OK @utf8_decode def matrix_timer_cb(server_name, remaining_calls): server = SERVERS[server_name] current_time = time.time() if ( (not server.connected) and server.reconnect_time and current_time >= (server.reconnect_time + server.reconnect_delay) ): server.reconnect() return W.WEECHAT_RC_OK if not server.connected or not server.client.logged_in: return W.WEECHAT_RC_OK # check lag, disconnect if it's too big server.lag = server.client.lag * 1000 server.lag_done = False W.bar_item_update("lag") if server.lag > G.CONFIG.network.lag_reconnect * 1000: server.disconnect() return W.WEECHAT_RC_OK for i, message in enumerate(server.client.outgoing_to_device_messages): if i >= 5: break if message in server.to_device_sent: continue server.to_device(message) server.to_device_sent.append(message) if server.sync_time and current_time > server.sync_time: timeout = 0 if server.transport_type == TransportType.HTTP else 30000 sync_filter = { "room": { "timeline": {"limit": 500}, "state": {"lazy_load_members": True} } } server.sync(timeout, sync_filter) if current_time > (server.user_gc_time + 3600): server.garbage_collect_users() return W.WEECHAT_RC_OK def create_default_server(config_file): server = MatrixServer("matrix_org", config_file._ptr) SERVERS[server.name] = server option = W.config_get(SCRIPT_NAME + ".server." + server.name + ".address") W.config_option_set(option, "matrix.org", 1) return True @utf8_decode def send_cb(server_name, file_descriptor): # type: (str, int) -> int server = SERVERS[server_name] if server.send_fd_hook: W.unhook(server.send_fd_hook) server.send_fd_hook = None if server.send_buffer: server.try_send(server.send_buffer) return W.WEECHAT_RC_OK weechat-matrix-0.3.0/matrix/uploads.py000066400000000000000000000263201407004440000177550ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Copyright © 2018, 2019 Damir Jelić # # Permission to use, copy, modify, and/or distribute this software for # any purpose with or without fee is hereby granted, provided that the # above copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. """Module implementing upload functionality.""" from __future__ import unicode_literals import attr import time import json from typing import Dict, Any from uuid import uuid1, UUID from enum import Enum try: from json.decoder import JSONDecodeError except ImportError: JSONDecodeError = ValueError # type: ignore from .globals import SCRIPT_NAME, SERVERS, W, UPLOADS from .utf import utf8_decode from .message_renderer import Render from matrix import globals as G from nio import Api class UploadState(Enum): created = 0 active = 1 finished = 2 error = 3 aborted = 4 @attr.s class Proxy(object): ptr = attr.ib(type=str) @property def name(self): return W.infolist_string(self.ptr, "name") @property def address(self): return W.infolist_string(self.ptr, "address") @property def type(self): return W.infolist_string(self.ptr, "type_string") @property def port(self): return str(W.infolist_integer(self.ptr, "port")) @property def user(self): return W.infolist_string(self.ptr, "username") @property def password(self): return W.infolist_string(self.ptr, "password") @attr.s class Upload(object): """Class representing an upload to a matrix server.""" server_name = attr.ib(type=str) server_address = attr.ib(type=str) access_token = attr.ib(type=str) room_id = attr.ib(type=str) filepath = attr.ib(type=str) encrypt = attr.ib(type=bool, default=False) file_keys = attr.ib(type=Dict, default=None) done = 0 total = 0 uuid = None buffer = None upload_hook = None content_uri = None file_name = None mimetype = "?" state = UploadState.created def __attrs_post_init__(self): self.uuid = uuid1() self.buffer = "" server = SERVERS[self.server_name] proxy_name = server.config.proxy proxy = None proxies_list = None if proxy_name: proxies_list = W.infolist_get("proxy", "", proxy_name) if proxies_list: W.infolist_next(proxies_list) proxy = Proxy(proxies_list) process_args = { "arg1": self.filepath, "arg2": self.server_address, "arg3": self.access_token, "buffer_flush": "1", } arg_count = 3 if self.encrypt: arg_count += 1 process_args["arg{}".format(arg_count)] = "--encrypt" if not server.config.ssl_verify: arg_count += 1 process_args["arg{}".format(arg_count)] = "--insecure" if proxy: arg_count += 1 process_args["arg{}".format(arg_count)] = "--proxy-type" arg_count += 1 process_args["arg{}".format(arg_count)] = proxy.type arg_count += 1 process_args["arg{}".format(arg_count)] = "--proxy-address" arg_count += 1 process_args["arg{}".format(arg_count)] = proxy.address arg_count += 1 process_args["arg{}".format(arg_count)] = "--proxy-port" arg_count += 1 process_args["arg{}".format(arg_count)] = proxy.port if proxy.user: arg_count += 1 process_args["arg{}".format(arg_count)] = "--proxy-user" arg_count += 1 process_args["arg{}".format(arg_count)] = proxy.user if proxy.password: arg_count += 1 process_args["arg{}".format(arg_count)] = "--proxy-password" arg_count += 1 process_args["arg{}".format(arg_count)] = proxy.password self.upload_hook = W.hook_process_hashtable( "matrix_upload", process_args, 0, "upload_cb", str(self.uuid) ) if proxies_list: W.infolist_free(proxies_list) def abort(self): pass @property def msgtype(self): # type: () -> str assert self.mimetype return Api.mimetype_to_msgtype(self.mimetype) @property def content(self): # type: () -> Dict[Any, Any] assert self.content_uri if self.encrypt: content = { "body": self.file_name, "msgtype": self.msgtype, "file": self.file_keys, } content["file"]["url"] = self.content_uri content["file"]["mimetype"] = self.mimetype # TODO thumbnail if it's an image return content return { "msgtype": self.msgtype, "body": self.file_name, "url": self.content_uri, } @property def render(self): # type: () -> str assert self.content_uri if self.encrypt: return Render.encrypted_media( self.content_uri, self.file_name, self.file_keys["key"]["k"], self.file_keys["hashes"]["sha256"], self.file_keys["iv"], ) return Render.media(self.content_uri, self.file_name) @attr.s class UploadsBuffer(object): """Weechat buffer showing the uploads for a server.""" _ptr = "" # type: str _selected_line = 0 # type: int uploads = UPLOADS def __attrs_post_init__(self): self._ptr = W.buffer_new( SCRIPT_NAME + ".uploads", "", "", "", "", ) W.buffer_set(self._ptr, "type", "free") W.buffer_set(self._ptr, "title", "Upload list") W.buffer_set(self._ptr, "key_bind_meta2-A", "/uploads up") W.buffer_set(self._ptr, "key_bind_meta2-B", "/uploads down") W.buffer_set(self._ptr, "localvar_set_type", "uploads") self.render() def move_line_up(self): self._selected_line = max(self._selected_line - 1, 0) self.render() def move_line_down(self): self._selected_line = min( self._selected_line + 1, len(self.uploads) - 1 ) self.render() def display(self): """Display the buffer.""" W.buffer_set(self._ptr, "display", "1") def render(self): """Render the new state of the upload buffer.""" # This function is under the MIT license. # Copyright (c) 2016 Vladimir Ignatev def progress(count, total): bar_len = 60 if total == 0: bar = '-' * bar_len return "[{}] {}%".format(bar, "?") filled_len = int(round(bar_len * count / float(total))) percents = round(100.0 * count / float(total), 1) bar = '=' * filled_len + '-' * (bar_len - filled_len) return "[{}] {}%".format(bar, percents) W.buffer_clear(self._ptr) header = "{}{}{}{}{}{}{}{}".format( W.color("green"), "Actions (letter+enter):", W.color("lightgreen"), " [A] Accept", " [C] Cancel", " [R] Remove", " [P] Purge finished", " [Q] Close this buffer" ) W.prnt_y(self._ptr, 0, header) for line_number, upload in enumerate(self.uploads.values()): line_color = "{},{}".format( "white" if line_number == self._selected_line else "default", "blue" if line_number == self._selected_line else "default", ) first_line = ("%s%s %-24s %s%s%s %s (%s.%s)" % ( W.color(line_color), "*** " if line_number == self._selected_line else " ", upload.room_id, "\"", upload.filepath, "\"", upload.mimetype, SCRIPT_NAME, upload.server_name, )) W.prnt_y(self._ptr, (line_number * 2) + 2, first_line) status_color = "{},{}".format("green", "blue") status = "{}{}{}".format( W.color(status_color), upload.state.name, W.color(line_color) ) second_line = ("{color}{prefix} {status} {progressbar} " "{done} / {total}").format( color=W.color(line_color), prefix="*** " if line_number == self._selected_line else " ", status=status, progressbar=progress(upload.done, upload.total), done=W.string_format_size(upload.done), total=W.string_format_size(upload.total)) W.prnt_y(self._ptr, (line_number * 2) + 3, second_line) def find_upload(uuid): return UPLOADS.get(uuid, None) def handle_child_message(upload, message): if message["type"] == "progress": upload.done = message["data"] elif message["type"] == "status": if message["status"] == "started": upload.state = UploadState.active upload.total = message["total"] upload.mimetype = message["mimetype"] upload.file_name = message["file_name"] elif message["status"] == "done": upload.state = UploadState.finished upload.content_uri = message["url"] upload.file_keys = message.get("file_keys", None) server = SERVERS.get(upload.server_name, None) if not server: return server.room_send_upload(upload) elif message["status"] == "error": upload.state = UploadState.error if G.CONFIG.upload_buffer: G.CONFIG.upload_buffer.render() @utf8_decode def upload_cb(data, command, return_code, out, err): upload = find_upload(UUID(data)) if not upload: return W.WEECHAT_RC_OK if return_code == W.WEECHAT_HOOK_PROCESS_ERROR: W.prnt("", "Error with command '%s'" % command) return W.WEECHAT_RC_OK if err != "": W.prnt("", "Error with command '%s'" % err) upload.state = UploadState.error if out != "": upload.buffer += out messages = upload.buffer.split("\n") upload.buffer = "" for m in messages: try: message = json.loads(m) except (JSONDecodeError, TypeError): upload.buffer += m continue handle_child_message(upload, message) return W.WEECHAT_RC_OK weechat-matrix-0.3.0/matrix/utf.py000066400000000000000000000102621407004440000171020ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Copyright (c) 2014-2016 Ryan Huber # Copyright (c) 2015-2016 Tollef Fog Heen # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. from __future__ import unicode_literals import sys # pylint: disable=redefined-builtin from builtins import bytes, str from functools import wraps if sys.version_info.major == 3 and sys.version_info.minor >= 3: from collections.abc import Iterable, Mapping else: from collections import Iterable, Mapping # These functions were written by Trygve Aaberge for wee-slack and are under a # MIT License. # More info can be found in the wee-slack repository under the commit: # 5e1c7e593d70972afb9a55f29d13adaf145d0166, the repository can be found at: # https://github.com/wee-slack/wee-slack class WeechatWrapper(object): def __init__(self, wrapped_class): self.wrapped_class = wrapped_class # Helper method used to encode/decode method calls. def wrap_for_utf8(self, method): def hooked(*args, **kwargs): result = method(*encode_to_utf8(args), **encode_to_utf8(kwargs)) # Prevent wrapped_class from becoming unwrapped if result == self.wrapped_class: return self return decode_from_utf8(result) return hooked # Encode and decode everything sent to/received from weechat. We use the # unicode type internally in wee-slack, but has to send utf8 to weechat. def __getattr__(self, attr): orig_attr = self.wrapped_class.__getattribute__(attr) if callable(orig_attr): return self.wrap_for_utf8(orig_attr) return decode_from_utf8(orig_attr) # Ensure all lines sent to weechat specify a prefix. For lines after the # first, we want to disable the prefix, which is done by specifying a # space. def prnt_date_tags(self, buffer, date, tags, message): message = message.replace("\n", "\n \t") return self.wrap_for_utf8(self.wrapped_class.prnt_date_tags)( buffer, date, tags, message ) def utf8_decode(function): """ Decode all arguments from byte strings to unicode strings. Use this for functions called from outside of this script, e.g. callbacks from weechat. """ @wraps(function) def wrapper(*args, **kwargs): # Don't do anything if we're python 3 if sys.hexversion >= 0x3000000: return function(*args, **kwargs) return function(*decode_from_utf8(args), **decode_from_utf8(kwargs)) return wrapper def decode_from_utf8(data): if isinstance(data, bytes): return data.decode("utf-8") if isinstance(data, str): return data elif isinstance(data, Mapping): return type(data)(map(decode_from_utf8, data.items())) elif isinstance(data, Iterable): return type(data)(map(decode_from_utf8, data)) return data def encode_to_utf8(data): if isinstance(data, str): return data.encode("utf-8") if isinstance(data, bytes): return data elif isinstance(data, Mapping): return type(data)(map(encode_to_utf8, data.items())) elif isinstance(data, Iterable): return type(data)(map(encode_to_utf8, data)) return data weechat-matrix-0.3.0/matrix/utils.py000066400000000000000000000132711407004440000174470ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Copyright © 2018, 2019 Damir Jelić # Copyright © 2018, 2019 Denis Kasak # # Permission to use, copy, modify, and/or distribute this software for # any purpose with or without fee is hereby granted, provided that the # above copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. from __future__ import unicode_literals, division import time from typing import Any, Dict, List from .globals import W if False: from .server import MatrixServer def key_from_value(dictionary, value): # type: (Dict[str, Any], Any) -> str return list(dictionary.keys())[list(dictionary.values()).index(value)] def server_buffer_prnt(server, string): # type: (MatrixServer, str) -> None assert server.server_buffer buffer = server.server_buffer now = int(time.time()) W.prnt_date_tags(buffer, now, "", string) def tags_from_line_data(line_data): # type: (str) -> List[str] tags_count = W.hdata_get_var_array_size( W.hdata_get("line_data"), line_data, "tags_array" ) tags = [ W.hdata_string( W.hdata_get("line_data"), line_data, "%d|tags_array" % i ) for i in range(tags_count) ] return tags def create_server_buffer(server): # type: (MatrixServer) -> None buffer_name = "server.{}".format(server.name) server.server_buffer = W.buffer_new( buffer_name, "server_buffer_cb", server.name, "", "" ) server_buffer_set_title(server) W.buffer_set(server.server_buffer, "short_name", server.name) W.buffer_set(server.server_buffer, "localvar_set_type", "server") W.buffer_set( server.server_buffer, "localvar_set_nick", server.config.username ) W.buffer_set(server.server_buffer, "localvar_set_server", server.name) W.buffer_set(server.server_buffer, "localvar_set_channel", server.name) server.buffer_merge() def server_buffer_set_title(server): # type: (MatrixServer) -> None if server.numeric_address: ip_string = " ({address})".format(address=server.numeric_address) else: ip_string = "" title = ("Matrix: {address}:{port}{ip}").format( address=server.address, port=server.config.port, ip=ip_string ) W.buffer_set(server.server_buffer, "title", title) def server_ts_to_weechat(timestamp): # type: (float) -> int date = int(timestamp / 1000) return date def strip_matrix_server(string): # type: (str) -> str return string.rsplit(":", 1)[0] def shorten_sender(sender): # type: (str) -> str return strip_matrix_server(sender)[1:] def string_strikethrough(string): return "".join(["{}\u0336".format(c) for c in string]) def string_color_and_reset(string, color): """Color string with color, then reset all attributes.""" lines = string.split('\n') lines = ("{}{}{}".format(W.color(color), line, W.color("reset")) for line in lines) return "\n".join(lines) def string_color(string, color): """Color string with color, then reset the color attribute.""" lines = string.split('\n') lines = ("{}{}{}".format(W.color(color), line, W.color("resetcolor")) for line in lines) return "\n".join(lines) def color_pair(color_fg, color_bg): """Make a color pair from a pair of colors.""" if color_bg: return "{},{}".format(color_fg, color_bg) else: return color_fg def text_block(text, margin=0): """ Pad block of text with whitespace to form a regular block, optionally adding a margin. """ # add vertical margin vertical_margin = margin // 2 text = "{}{}{}".format( "\n" * vertical_margin, text, "\n" * vertical_margin ) lines = text.split("\n") longest_len = max(len(l) for l in lines) + margin # pad block and add horizontal margin text = "\n".join( "{pre}{line}{post}".format( pre=" " * margin, line=l, post=" " * (longest_len - len(l))) for l in lines) return text def colored_text_block(text, margin=0, color_pair=""): """ Like text_block, but also colors it.""" return string_color_and_reset(text_block(text, margin=margin), color_pair) def parse_redact_args(args): args = args.strip() had_example_text = False try: event_id, rest = args.split("|", 1) had_example_text = True except ValueError: try: event_id, rest = args.split(" ", 1) except ValueError: event_id, rest = (args, "") if had_example_text: rest = rest.lstrip() reason = None # until it has been correctly determined if rest[0] == '"': escaped = False for i in range(1, len(rest)): if escaped: escaped = False elif rest[i] == "\\": escaped = True elif rest[i] == '"': reason = rest[i+1:] break else: reason = rest event_id = event_id.strip() if reason: reason = reason.strip() # The reason might be an empty string, set it to None if so else: reason = None return event_id, reason weechat-matrix-0.3.0/pyproject.toml000066400000000000000000000017131407004440000173430ustar00rootroot00000000000000[tool.poetry] name = "matrix" version = "0.3.0" license = "ISC" description = "Weechat protocol script for Matrix." authors = ["Damir Jelić "] packages = [ { include = "matrix" }, { include = "contrib/*.py", format = "sdist" }, { include = "main.py", format = "sdist" }, ] [tool.poetry.dependencies] python = "^3.6" pyOpenSSL = "^19.1.0" webcolors = "^1.11.1" atomicwrites = "^1.3.0" future = "^0.18.2" attrs = "^19.3.0" logbook = "^1.5.3" pygments = "^2.6.1" matrix-nio = { version = "^0.18.0", extras = [ "e2e" ] } python-magic = { version = "^0.4.15", optional = true } aiohttp = { version = "^3.6.2", optional = true } requests = { version = "^2.23.0", optional = true } typing = { version = "^3.7.4", python = "<3.5" } [tool.poetry.extras] matrix_decrypt = ["requests"] matrix_sso_helper = ["aiohttp"] matrix_upload = ["python-magic", "requests"] [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" weechat-matrix-0.3.0/requirements.txt000066400000000000000000000003041407004440000177060ustar00rootroot00000000000000pyOpenSSL typing ; python_version < "3.5" webcolors future; python_version < "3.2" atomicwrites attrs logbook pygments matrix-nio[e2e]>=0.6 aiohttp ; python_version >= "3.5" python-magic requests weechat-matrix-0.3.0/tests/000077500000000000000000000000001407004440000155675ustar00rootroot00000000000000weechat-matrix-0.3.0/tests/buffer_test.py000066400000000000000000000051711407004440000204550ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import unicode_literals from matrix.buffer import WeechatChannelBuffer from matrix.utils import parse_redact_args class TestClass(object): def test_buffer(self): b = WeechatChannelBuffer("test_buffer_name", "example.org", "alice") assert b def test_buffer_print(self): b = WeechatChannelBuffer("test_buffer_name", "example.org", "alice") b.message("alice", "hello world", 0, 0) assert b def test_redact_args_parse(self): args = '$81wbnOYZllVZJcstsnXpq7dmugA775-JT4IB-uPT680|"Hello world" No specific reason' event_id, reason = parse_redact_args(args) assert event_id == '$81wbnOYZllVZJcstsnXpq7dmugA775-JT4IB-uPT680' assert reason == 'No specific reason' args = '$15677776791893pZSXx:example.org|"Hello world" No reason at all' event_id, reason = parse_redact_args(args) assert event_id == '$15677776791893pZSXx:example.org' assert reason == 'No reason at all' args = '$15677776791893pZSXx:example.org No reason at all' event_id, reason = parse_redact_args(args) assert event_id == '$15677776791893pZSXx:example.org' assert reason == 'No reason at all' args = '$81wbnOYZllVZJcstsnXpq7dmugA775-JT4IB-uPT680 No specific reason' event_id, reason = parse_redact_args(args) assert event_id == '$81wbnOYZllVZJcstsnXpq7dmugA775-JT4IB-uPT680' assert reason == 'No specific reason' args = '$81wbnOYZllVZJcstsnXpq7dmugA775-JT4IB-uPT680' event_id, reason = parse_redact_args(args) assert event_id == '$81wbnOYZllVZJcstsnXpq7dmugA775-JT4IB-uPT680' assert reason == None args = '$15677776791893pZSXx:example.org' event_id, reason = parse_redact_args(args) assert event_id == '$15677776791893pZSXx:example.org' assert reason == None args = ' ' event_id, reason = parse_redact_args(args) assert event_id == '' assert reason == None args = '$15677776791893pZSXx:example.org|"Hello world"' event_id, reason = parse_redact_args(args) assert event_id == '$15677776791893pZSXx:example.org' assert reason == None args = '$15677776791893pZSXx:example.org|"Hello world' event_id, reason = parse_redact_args(args) assert event_id == '$15677776791893pZSXx:example.org' assert reason == None args = '$15677776791893pZSXx:example.org "Hello world"' event_id, reason = parse_redact_args(args) assert event_id == '$15677776791893pZSXx:example.org' assert reason == '"Hello world"' weechat-matrix-0.3.0/tests/color_test.py000066400000000000000000000111371407004440000203210ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import unicode_literals import webcolors from collections import OrderedDict from hypothesis import given from hypothesis.strategies import sampled_from, text, characters from matrix.colors import (G, Formatted, FormattedString, color_html_to_weechat, color_weechat_to_html) from matrix._weechat import MockConfig G.CONFIG = MockConfig() html_prism = ("Test") weechat_prism = ( u"\x1b[038;5;1mT\x1b[039m\x1b[038;5;9me\x1b[039m\x1b[038;5;3ms\x1b[039m\x1b[038;5;11mt\x1b[039m" ) first_16_html_colors = list(webcolors.HTML4_HEX_TO_NAMES.values()) def test_prism(): formatted = Formatted.from_html(html_prism) assert formatted.to_weechat() == weechat_prism @given(sampled_from(first_16_html_colors)) def test_color_conversion(color_name): hex_color = color_weechat_to_html(color_html_to_weechat(color_name)) new_color_name = webcolors.hex_to_name(hex_color, spec='html4') assert new_color_name == color_name def test_handle_strikethrough_first(): valid_result = '\x1b[038;5;1mf̶o̶o̶\x1b[039m' d1 = OrderedDict([('fgcolor', 'red'), ('strikethrough', True)]) d2 = OrderedDict([('strikethrough', True), ('fgcolor', 'red'), ]) f1 = Formatted([FormattedString('foo', d1)]) f2 = Formatted([FormattedString('foo', d2)]) assert f1.to_weechat() == valid_result assert f2.to_weechat() == valid_result def test_normalize_spaces_in_inline_code(): """Normalize spaces in inline code blocks. Strips leading and trailing spaces and compress consecutive infix spaces. """ valid_result = '\x1b[0m* a *\x1b[00m' formatted = Formatted.from_input_line('` * a * `') assert formatted.to_weechat() == valid_result @given( text(alphabet=characters(min_codepoint=32, blacklist_characters="*_`\\")) .map(lambda s: '*' + s)) def test_unpaired_prefix_asterisk_without_space_is_literal(text): """An unpaired asterisk at the beginning of the line, without a space after it, is considered literal. """ formatted = Formatted.from_input_line(text) assert text.strip() == formatted.to_weechat() def test_input_line_color(): formatted = Formatted.from_input_line("\x0304Hello") assert "\x1b[038;5;9mHello\x1b[039m" == formatted.to_weechat() assert "Hello" == formatted.to_html() def test_input_line_bold(): formatted = Formatted.from_input_line("\x02Hello") assert "\x1b[01mHello\x1b[021m" == formatted.to_weechat() assert "Hello" == formatted.to_html() def test_input_line_underline(): formatted = Formatted.from_input_line("\x1FHello") assert "\x1b[04mHello\x1b[024m" == formatted.to_weechat() assert "Hello" == formatted.to_html() def test_input_line_markdown_emph(): formatted = Formatted.from_input_line("*Hello*") assert "\x1b[03mHello\x1b[023m" == formatted.to_weechat() assert "Hello" == formatted.to_html() def test_input_line_markdown_bold(): formatted = Formatted.from_input_line("**Hello**") assert "\x1b[01mHello\x1b[021m" == formatted.to_weechat() assert "Hello" == formatted.to_html() def test_input_line_markdown_various(): inp = "**bold* bold *bital etc* bold **bold** * *italic*" formatted = Formatted.from_input_line(inp) assert "bold* bold " \ "bital etc bold **bold" \ " * italic" \ == formatted.to_html() def test_input_line_markdown_various2(): inp = "norm** `code **code *code` norm `norm" formatted = Formatted.from_input_line(inp) assert "norm** code **code *code norm `norm" \ == formatted.to_html() def test_input_line_backslash(): def convert(s): return Formatted.from_input_line(s).to_html() assert "pre italic* ital norm" == convert("pre *italic\\* ital* norm") assert "*norm* norm" == convert("\\*norm* norm") assert "*ital" == convert("*\\*ital*") assert "C:\\path" == convert("`C:\\path`") assert "with`tick" == convert("`with\\`tick`") assert "`un`matched" == convert("`un\\`matched") assert "bold *bital norm" == convert("**bold *\\*bital*** norm") def test_conversion(): formatted = Formatted.from_input_line("*Hello*") formatted2 = Formatted.from_html(formatted.to_html()) formatted.to_weechat() == formatted2.to_weechat() weechat-matrix-0.3.0/tests/http_parser_test.py000066400000000000000000000033641407004440000215410ustar00rootroot00000000000000import html.entities from hypothesis import given from hypothesis.strategies import sampled_from from matrix.colors import MatrixHtmlParser try: # python 3 html_entities = [(name, char, ord(char)) for name, char in html.entities.html5.items() if not name.endswith(';')] except AttributeError: # python 2 html_entities = [(name, unichr(codepoint), codepoint) for name, codepoint in html.entities.name2codepoint.items()] @given(sampled_from(html_entities)) def test_html_named_entity_parsing(entitydef): name = entitydef[0] character = entitydef[1] parser = MatrixHtmlParser() assert parser.unescape('&{};'.format(name)) == character @given(sampled_from(html_entities)) def test_html_numeric_reference_parsing(entitydef): character = entitydef[1] num = entitydef[2] parser = MatrixHtmlParser() assert parser.unescape('&#{};'.format(num)) == character @given(sampled_from(html_entities)) def test_html_entityref_reconstruction_from_name(entitydef): name = entitydef[0] parser = MatrixHtmlParser() parser.handle_entityref(name) s = parser.get_substrings() assert s[0].text == parser.unescape('&{};'.format(name)) and len(s) == 1 @given(sampled_from(html_entities)) def test_html_charref_reconstruction_from_name(entitydef): num = entitydef[2] parser = MatrixHtmlParser() parser.handle_charref(num) s = parser.get_substrings() assert s[0].text == parser.unescape('&#{};'.format(num)) and len(s) == 1 def test_parsing_of_escaped_brackets(): p = MatrixHtmlParser() p.feed('
<faketag>
') s = p.get_substrings() assert s[0].text == '' and len(s) == 1 weechat-matrix-0.3.0/tests/server_test.py000066400000000000000000000014351407004440000205110ustar00rootroot00000000000000from matrix.server import MatrixServer from matrix._weechat import MockConfig import matrix.globals as G G.CONFIG = MockConfig() class TestClass(object): def test_address_parsing(self): homeserver = MatrixServer._parse_url("example.org", 8080) assert homeserver.hostname == "example.org" assert homeserver.geturl() == "https://example.org:8080" homeserver = MatrixServer._parse_url("example.org/_matrix", 80) assert homeserver.hostname == "example.org" assert homeserver.geturl() == "https://example.org:80/_matrix" homeserver = MatrixServer._parse_url( "https://example.org/_matrix", 80 ) assert homeserver.hostname == "example.org" assert homeserver.geturl() == "https://example.org:80/_matrix"