├── .gitignore ├── .pylintrc ├── .travis.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── contrib ├── matrix_decrypt.py ├── matrix_sso_helper.py └── matrix_upload.py ├── main.py ├── matrix ├── __init__.py ├── _weechat.py ├── bar_items.py ├── buffer.py ├── colors.py ├── commands.py ├── completion.py ├── config.py ├── globals.py ├── message_renderer.py ├── server.py ├── uploads.py ├── utf.py └── utils.py ├── pyproject.toml ├── requirements.txt └── tests ├── buffer_test.py ├── color_test.py ├── http_parser_test.py └── server_test.py /.gitignore: -------------------------------------------------------------------------------- 1 | **/*.pyc 2 | **/.*\.py@neomake*\.py 3 | .hypothesis/ 4 | .mypy_cache/ 5 | .pytest_cache/ 6 | .ropeproject 7 | .coverage 8 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code 6 | extension-pkg-whitelist= 7 | 8 | # Add files or directories to the blacklist. They should be base names, not 9 | # paths. 10 | ignore=CVS 11 | 12 | # Add files or directories matching the regex patterns to the blacklist. The 13 | # regex matches against base names, not paths. 14 | ignore-patterns= 15 | 16 | # Python code to execute, usually for sys.path manipulation such as 17 | # pygtk.require(). 18 | #init-hook= 19 | 20 | # Use multiple processes to speed up Pylint. 21 | jobs=1 22 | 23 | # List of plugins (as comma separated values of python modules names) to load, 24 | # usually to register additional checkers. 25 | load-plugins= 26 | 27 | # Pickle collected data for later comparisons. 28 | persistent=yes 29 | 30 | # Specify a configuration file. 31 | #rcfile= 32 | 33 | # When enabled, pylint would attempt to guess common misconfiguration and emit 34 | # user-friendly hints instead of false-positive error messages 35 | suggestion-mode=yes 36 | 37 | # Allow loading of arbitrary C extensions. Extensions are imported into the 38 | # active Python interpreter and may run arbitrary code. 39 | unsafe-load-any-extension=no 40 | 41 | 42 | [MESSAGES CONTROL] 43 | 44 | # Only show warnings with the listed confidence levels. Leave empty to show 45 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 46 | confidence= 47 | 48 | # Disable the message, report, category or checker with the given id(s). You 49 | # can either give multiple identifiers separated by comma (,) or put this 50 | # option multiple times (only on the command line, not in the configuration 51 | # file where it should appear only once).You can also use "--disable=all" to 52 | # disable everything first and then reenable specific checks. For example, if 53 | # you want to run only the similarities checker, you can use "--disable=all 54 | # --enable=similarities". If you want to run only the classes checker, but have 55 | # no Warning level messages displayed, use"--disable=all --enable=classes 56 | # --disable=W" 57 | disable=print-statement, 58 | parameter-unpacking, 59 | unpacking-in-except, 60 | old-raise-syntax, 61 | backtick, 62 | long-suffix, 63 | old-ne-operator, 64 | old-octal-literal, 65 | import-star-module-level, 66 | non-ascii-bytes-literal, 67 | raw-checker-failed, 68 | bad-inline-option, 69 | locally-disabled, 70 | locally-enabled, 71 | file-ignored, 72 | suppressed-message, 73 | useless-suppression, 74 | deprecated-pragma, 75 | apply-builtin, 76 | basestring-builtin, 77 | buffer-builtin, 78 | cmp-builtin, 79 | coerce-builtin, 80 | execfile-builtin, 81 | file-builtin, 82 | long-builtin, 83 | raw_input-builtin, 84 | reduce-builtin, 85 | standarderror-builtin, 86 | unicode-builtin, 87 | xrange-builtin, 88 | coerce-method, 89 | delslice-method, 90 | getslice-method, 91 | setslice-method, 92 | no-absolute-import, 93 | old-division, 94 | dict-iter-method, 95 | dict-view-method, 96 | next-method-called, 97 | metaclass-assignment, 98 | indexing-exception, 99 | raising-string, 100 | reload-builtin, 101 | oct-method, 102 | hex-method, 103 | nonzero-method, 104 | cmp-method, 105 | input-builtin, 106 | round-builtin, 107 | intern-builtin, 108 | unichr-builtin, 109 | map-builtin-not-iterating, 110 | zip-builtin-not-iterating, 111 | range-builtin-not-iterating, 112 | filter-builtin-not-iterating, 113 | using-cmp-argument, 114 | eq-without-hash, 115 | div-method, 116 | idiv-method, 117 | rdiv-method, 118 | exception-message-attribute, 119 | invalid-str-codec, 120 | sys-max-int, 121 | bad-python3-import, 122 | deprecated-string-function, 123 | deprecated-str-translate-call, 124 | deprecated-itertools-function, 125 | deprecated-types-field, 126 | next-method-defined, 127 | dict-items-not-iterating, 128 | dict-keys-not-iterating, 129 | dict-values-not-iterating, 130 | bad-whitespace, 131 | too-few-public-methods, 132 | too-many-lines, 133 | missing-docstring, 134 | bad-continuation, 135 | 136 | # Enable the message, report, category or checker with the given id(s). You can 137 | # either give multiple identifier separated by comma (,) or put this option 138 | # multiple time (only on the command line, not in the configuration file where 139 | # it should appear only once). See also the "--disable" option for examples. 140 | enable=c-extension-no-member 141 | 142 | 143 | [REPORTS] 144 | 145 | # Python expression which should return a note less than 10 (10 is the highest 146 | # note). You have access to the variables errors warning, statement which 147 | # respectively contain the number of errors / warnings messages and the total 148 | # number of statements analyzed. This is used by the global evaluation report 149 | # (RP0004). 150 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 151 | 152 | # Template used to display messages. This is a python new-style format string 153 | # used to format the message information. See doc for all details 154 | #msg-template= 155 | 156 | # Set the output format. Available formats are text, parseable, colorized, json 157 | # and msvs (visual studio).You can also give a reporter class, eg 158 | # mypackage.mymodule.MyReporterClass. 159 | output-format=text 160 | 161 | # Tells whether to display a full report or only the messages 162 | reports=no 163 | 164 | # Activate the evaluation score. 165 | score=yes 166 | 167 | 168 | [REFACTORING] 169 | 170 | # Maximum number of nested blocks for function / method body 171 | max-nested-blocks=5 172 | 173 | 174 | [VARIABLES] 175 | 176 | # List of additional names supposed to be defined in builtins. Remember that 177 | # you should avoid to define new builtins when possible. 178 | additional-builtins= 179 | 180 | # Tells whether unused global variables should be treated as a violation. 181 | allow-global-unused-variables=yes 182 | 183 | # List of strings which can identify a callback function by name. A callback 184 | # name must start or end with one of those strings. 185 | callbacks=cb_, 186 | _cb 187 | 188 | # A regular expression matching the name of dummy variables (i.e. expectedly 189 | # not used). 190 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 191 | 192 | # Argument names that match this expression will be ignored. Default to name 193 | # with leading underscore 194 | ignored-argument-names=_.*|^ignored_|^unused_ 195 | 196 | # Tells whether we should check for unused import in __init__ files. 197 | init-import=no 198 | 199 | # List of qualified module names which can have objects that can redefine 200 | # builtins. 201 | redefining-builtins-modules=six.moves,past.builtins,future.builtins 202 | 203 | 204 | [TYPECHECK] 205 | 206 | # List of decorators that produce context managers, such as 207 | # contextlib.contextmanager. Add to this list to register other decorators that 208 | # produce valid context managers. 209 | contextmanager-decorators=contextlib.contextmanager 210 | 211 | # List of members which are set dynamically and missed by pylint inference 212 | # system, and so shouldn't trigger E1101 when accessed. Python regular 213 | # expressions are accepted. 214 | generated-members= 215 | 216 | # Tells whether missing members accessed in mixin class should be ignored. A 217 | # mixin class is detected if its name ends with "mixin" (case insensitive). 218 | ignore-mixin-members=yes 219 | 220 | # This flag controls whether pylint should warn about no-member and similar 221 | # checks whenever an opaque object is returned when inferring. The inference 222 | # can return multiple potential results while evaluating a Python object, but 223 | # some branches might not be evaluated, which results in partial inference. In 224 | # that case, it might be useful to still emit no-member and other checks for 225 | # the rest of the inferred objects. 226 | ignore-on-opaque-inference=yes 227 | 228 | # List of class names for which member attributes should not be checked (useful 229 | # for classes with dynamically set attributes). This supports the use of 230 | # qualified names. 231 | ignored-classes=optparse.Values,thread._local,_thread._local 232 | 233 | # List of module names for which member attributes should not be checked 234 | # (useful for modules/projects where namespaces are manipulated during runtime 235 | # and thus existing member attributes cannot be deduced by static analysis. It 236 | # supports qualified module names, as well as Unix pattern matching. 237 | ignored-modules= 238 | 239 | # Show a hint with possible names when a member name was not found. The aspect 240 | # of finding the hint is based on edit distance. 241 | missing-member-hint=yes 242 | 243 | # The minimum edit distance a name should have in order to be considered a 244 | # similar match for a missing member name. 245 | missing-member-hint-distance=1 246 | 247 | # The total number of similar names that should be taken in consideration when 248 | # showing a hint for a missing member. 249 | missing-member-max-choices=1 250 | 251 | 252 | [SPELLING] 253 | 254 | # Limits count of emitted suggestions for spelling mistakes 255 | max-spelling-suggestions=4 256 | 257 | # Spelling dictionary name. Available dictionaries: none. To make it working 258 | # install python-enchant package. 259 | spelling-dict= 260 | 261 | # List of comma separated words that should not be checked. 262 | spelling-ignore-words= 263 | 264 | # A path to a file that contains private dictionary; one word per line. 265 | spelling-private-dict-file= 266 | 267 | # Tells whether to store unknown words to indicated private dictionary in 268 | # --spelling-private-dict-file option instead of raising a message. 269 | spelling-store-unknown-words=no 270 | 271 | 272 | [SIMILARITIES] 273 | 274 | # Ignore comments when computing similarities. 275 | ignore-comments=yes 276 | 277 | # Ignore docstrings when computing similarities. 278 | ignore-docstrings=yes 279 | 280 | # Ignore imports when computing similarities. 281 | ignore-imports=no 282 | 283 | # Minimum lines number of a similarity. 284 | min-similarity-lines=4 285 | 286 | 287 | [MISCELLANEOUS] 288 | 289 | # List of note tags to take in consideration, separated by a comma. 290 | notes=FIXME, 291 | XXX, 292 | TODO 293 | 294 | 295 | [LOGGING] 296 | 297 | # Logging modules to check that the string format arguments are in logging 298 | # function parameter format 299 | logging-modules=logging 300 | 301 | 302 | [FORMAT] 303 | 304 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 305 | expected-line-ending-format= 306 | 307 | # Regexp for a line that is allowed to be longer than the limit. 308 | ignore-long-lines=^\s*(# )??$ 309 | 310 | # Number of spaces of indent required inside a hanging or continued line. 311 | indent-after-paren=4 312 | 313 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 314 | # tab). 315 | indent-string=' ' 316 | 317 | # Maximum number of characters on a single line. 318 | max-line-length=100 319 | 320 | # Maximum number of lines in a module 321 | max-module-lines=1000 322 | 323 | # List of optional constructs for which whitespace checking is disabled. `dict- 324 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 325 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 326 | # `empty-line` allows space-only lines. 327 | no-space-check=trailing-comma, 328 | dict-separator 329 | 330 | # Allow the body of a class to be on the same line as the declaration if body 331 | # contains single statement. 332 | single-line-class-stmt=no 333 | 334 | # Allow the body of an if to be on the same line as the test if there is no 335 | # else. 336 | single-line-if-stmt=no 337 | 338 | 339 | [BASIC] 340 | 341 | # Naming style matching correct argument names 342 | argument-naming-style=snake_case 343 | 344 | # Regular expression matching correct argument names. Overrides argument- 345 | # naming-style 346 | #argument-rgx= 347 | 348 | # Naming style matching correct attribute names 349 | attr-naming-style=snake_case 350 | 351 | # Regular expression matching correct attribute names. Overrides attr-naming- 352 | # style 353 | #attr-rgx= 354 | 355 | # Bad variable names which should always be refused, separated by a comma 356 | bad-names=foo, 357 | bar, 358 | baz, 359 | toto, 360 | tutu, 361 | tata 362 | 363 | # Naming style matching correct class attribute names 364 | class-attribute-naming-style=any 365 | 366 | # Regular expression matching correct class attribute names. Overrides class- 367 | # attribute-naming-style 368 | #class-attribute-rgx= 369 | 370 | # Naming style matching correct class names 371 | class-naming-style=PascalCase 372 | 373 | # Regular expression matching correct class names. Overrides class-naming-style 374 | #class-rgx= 375 | 376 | # Naming style matching correct constant names 377 | const-naming-style=UPPER_CASE 378 | 379 | # Regular expression matching correct constant names. Overrides const-naming- 380 | # style 381 | #const-rgx= 382 | 383 | # Minimum line length for functions/classes that require docstrings, shorter 384 | # ones are exempt. 385 | docstring-min-length=-1 386 | 387 | # Naming style matching correct function names 388 | function-naming-style=snake_case 389 | 390 | # Regular expression matching correct function names. Overrides function- 391 | # naming-style 392 | #function-rgx= 393 | 394 | # Good variable names which should always be accepted, separated by a comma 395 | good-names=i, 396 | j, 397 | k, 398 | ex, 399 | Run, 400 | _ 401 | 402 | # Include a hint for the correct naming format with invalid-name 403 | include-naming-hint=no 404 | 405 | # Naming style matching correct inline iteration names 406 | inlinevar-naming-style=any 407 | 408 | # Regular expression matching correct inline iteration names. Overrides 409 | # inlinevar-naming-style 410 | #inlinevar-rgx= 411 | 412 | # Naming style matching correct method names 413 | method-naming-style=snake_case 414 | 415 | # Regular expression matching correct method names. Overrides method-naming- 416 | # style 417 | #method-rgx= 418 | 419 | # Naming style matching correct module names 420 | module-naming-style=snake_case 421 | 422 | # Regular expression matching correct module names. Overrides module-naming- 423 | # style 424 | #module-rgx= 425 | 426 | # Colon-delimited sets of names that determine each other's naming style when 427 | # the name regexes allow several styles. 428 | name-group= 429 | 430 | # Regular expression which should only match function or class names that do 431 | # not require a docstring. 432 | no-docstring-rgx=^_ 433 | 434 | # List of decorators that produce properties, such as abc.abstractproperty. Add 435 | # to this list to register other decorators that produce valid properties. 436 | property-classes=abc.abstractproperty 437 | 438 | # Naming style matching correct variable names 439 | variable-naming-style=snake_case 440 | 441 | # Regular expression matching correct variable names. Overrides variable- 442 | # naming-style 443 | #variable-rgx= 444 | 445 | 446 | [IMPORTS] 447 | 448 | # Allow wildcard imports from modules that define __all__. 449 | allow-wildcard-with-all=no 450 | 451 | # Analyse import fallback blocks. This can be used to support both Python 2 and 452 | # 3 compatible code, which means that the block might have code that exists 453 | # only in one or another interpreter, leading to false positives when analysed. 454 | analyse-fallback-blocks=no 455 | 456 | # Deprecated modules which should not be used, separated by a comma 457 | deprecated-modules=optparse,tkinter.tix 458 | 459 | # Create a graph of external dependencies in the given file (report RP0402 must 460 | # not be disabled) 461 | ext-import-graph= 462 | 463 | # Create a graph of every (i.e. internal and external) dependencies in the 464 | # given file (report RP0402 must not be disabled) 465 | import-graph= 466 | 467 | # Create a graph of internal dependencies in the given file (report RP0402 must 468 | # not be disabled) 469 | int-import-graph= 470 | 471 | # Force import order to recognize a module as part of the standard 472 | # compatibility libraries. 473 | known-standard-library= 474 | 475 | # Force import order to recognize a module as part of a third party library. 476 | known-third-party=enchant 477 | 478 | 479 | [DESIGN] 480 | 481 | # Maximum number of arguments for function / method 482 | max-args=5 483 | 484 | # Maximum number of attributes for a class (see R0902). 485 | max-attributes=7 486 | 487 | # Maximum number of boolean expressions in a if statement 488 | max-bool-expr=5 489 | 490 | # Maximum number of branch for function / method body 491 | max-branches=12 492 | 493 | # Maximum number of locals for function / method body 494 | max-locals=15 495 | 496 | # Maximum number of parents for a class (see R0901). 497 | max-parents=7 498 | 499 | # Maximum number of public methods for a class (see R0904). 500 | max-public-methods=20 501 | 502 | # Maximum number of return / yield for function / method body 503 | max-returns=6 504 | 505 | # Maximum number of statements in function / method body 506 | max-statements=50 507 | 508 | # Minimum number of public methods for a class (see R0903). 509 | min-public-methods=2 510 | 511 | 512 | [CLASSES] 513 | 514 | # List of method names used to declare (i.e. assign) instance attributes. 515 | defining-attr-methods=__init__, 516 | __new__, 517 | setUp 518 | 519 | # List of member names, which should be excluded from the protected access 520 | # warning. 521 | exclude-protected=_asdict, 522 | _fields, 523 | _replace, 524 | _source, 525 | _make 526 | 527 | # List of valid names for the first argument in a class method. 528 | valid-classmethod-first-arg=cls 529 | 530 | # List of valid names for the first argument in a metaclass class method. 531 | valid-metaclass-classmethod-first-arg=mcs 532 | 533 | 534 | [EXCEPTIONS] 535 | 536 | # Exceptions that will emit a warning when being caught. Defaults to 537 | # "Exception" 538 | overgeneral-exceptions=Exception 539 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ## Machine config 3 | os: "linux" 4 | arch: "amd64" 5 | dist: "bionic" 6 | version: "~> 1.0" 7 | 8 | ## Language config 9 | language: "python" 10 | python: 11 | - "3.6" 12 | - "3.7" 13 | - "3.8" 14 | 15 | before_install: 16 | - wget https://gitlab.matrix.org/matrix-org/olm/-/archive/master/olm-master.tar.bz2 17 | - tar -xvf olm-master.tar.bz2 18 | - pushd olm-master && make && sudo make PREFIX="/usr" install && popd 19 | - rm -r olm-master 20 | 21 | install: 22 | - pip install -r requirements.txt 23 | - pip install pytest 24 | - pip install hypothesis 25 | 26 | script: python -m pytest 27 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:testing-slim 2 | 3 | RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections 4 | RUN apt-get update -y; apt-get install -q -y \ 5 | git \ 6 | libolm-dev \ 7 | python3 \ 8 | python3-pip \ 9 | weechat-curses \ 10 | weechat-python \ 11 | && apt-get clean \ 12 | && rm -rf /var/lib/apt/lists/* \ 13 | && rm -fr /root/.cache 14 | 15 | # add chat user 16 | RUN useradd -ms /bin/bash chat && mkdir /var/build 17 | 18 | # get and build source code 19 | WORKDIR /var/build 20 | RUN git clone https://github.com/poljar/weechat-matrix.git 21 | WORKDIR /var/build/weechat-matrix 22 | RUN pip3 install -r requirements.txt 23 | 24 | # Install and setup autoloading 25 | USER chat 26 | RUN make install 27 | WORKDIR /home/chat 28 | RUN mkdir -p .weechat/python/autoload && ln -s /home/chat/.weechat/python/matrix.py /home/chat/.weechat/python/autoload/ 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Weechat Matrix Protocol Script 2 | Copyright © 2018 Damir Jelić 3 | 4 | Permission to use, copy, modify, and/or distribute this software for 5 | any purpose with or without fee is hereby granted, provided that the 6 | above copyright notice and this permission notice appear in all copies. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 11 | SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER 12 | RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF 13 | CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 14 | CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: install install-lib install-dir uninstall phony test typecheck 2 | 3 | XDG_DATA_HOME ?= $(HOME)/.local/share 4 | 5 | ifneq ("$(wildcard $(XDG_DATA_HOME)/weechat)","") 6 | WEECHAT_HOME ?= $(XDG_DATA_HOME)/weechat 7 | else 8 | WEECHAT_HOME ?= $(HOME)/.weechat 9 | endif 10 | PREFIX ?= $(WEECHAT_HOME) 11 | 12 | INSTALLDIR := $(DESTDIR)$(PREFIX)/python/matrix 13 | 14 | lib := $(patsubst matrix/%.py, $(INSTALLDIR)/%.py, \ 15 | $(wildcard matrix/*.py)) 16 | 17 | .PHONY: help 18 | help: 19 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 20 | 21 | install: install-lib | $(INSTALLDIR) ## Install the plugin to $(DESTDIR)/$(PREFIX) 22 | install -m644 main.py $(DESTDIR)$(PREFIX)/python/matrix.py 23 | 24 | install-lib: $(lib) 25 | $(INSTALLDIR): 26 | install -d $@ 27 | 28 | uninstall: ## Uninstall the plugin from $(PREFIX) 29 | rm $(DESTDIR)$(PREFIX)/python/matrix.py $(INSTALLDIR)/* 30 | rmdir $(INSTALLDIR) 31 | 32 | phony: 33 | 34 | $(INSTALLDIR)/%.py: matrix/%.py phony | $(INSTALLDIR) 35 | install -m644 $< $@ 36 | 37 | test: ## Run automated tests 38 | python3 -m pytest 39 | 40 | typecheck: ## Run type check 41 | mypy -p matrix --ignore-missing-imports --warn-redundant-casts 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://img.shields.io/travis/poljar/weechat-matrix.svg?style=flat-square)](https://travis-ci.org/poljar/weechat-matrix) 2 | [![#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) 3 | [![license](https://img.shields.io/badge/license-ISC-blue.svg?style=flat-square)](https://github.com/poljar/weechat-matrix/blob/master/LICENSE) 4 | 5 | # What is Weechat-Matrix? 6 | 7 | [Weechat](https://weechat.org/) is an extensible chat client. 8 | 9 | [Matrix](https://matrix.org/blog/home) is an open network for secure, 10 | decentralized communication. 11 | 12 | [weechat-matrix](https://github.com/poljar/weechat-matrix/) is a Python script 13 | for Weechat that lets Weechat communicate over the Matrix protocol. 14 | 15 | # Project Status 16 | 17 | weechat-matrix is stable and quite usable as a daily driver. It already 18 | supports large parts of the Matrix protocol, including end-to-end encryption 19 | (though some features like cross-signing and session unwedging are 20 | unimplemented). 21 | 22 | However, due to some inherent limitations of Weechat *scripts*, development has 23 | moved to [weechat-matrix-rs](https://github.com/poljar/weechat-matrix-rs), 24 | a Weechat *plugin* written in Rust. As such, weechat-matrix is in maintenance 25 | mode and will likely not be receiving substantial new features. PRs are still 26 | accepted and welcome. 27 | 28 | # Installation 29 | 30 | ## Arch Linux 31 | 32 | Packaged as `community/weechat-matrix`. 33 | 34 | pacman -S weechat-matrix 35 | 36 | ## Alpine Linux 37 | 38 | apk add weechat-matrix 39 | 40 | Then follow the instructions printed during installation to make the script 41 | available to weechat. 42 | 43 | ## Other platforms 44 | 45 | 1. Install libolm 3.1+ 46 | 47 | - Debian 11+ (testing/sid) or Ubuntu 19.10+ install libolm-dev 48 | 49 | - FreeBSD `pkg install olm` 50 | 51 | - macOS `brew install libolm` 52 | 53 | - Failing any of the above see https://gitlab.matrix.org/matrix-org/olm 54 | for instructions about building it from sources 55 | 56 | 2. Clone the repo and install dependencies 57 | ``` 58 | git clone https://github.com/poljar/weechat-matrix.git 59 | cd weechat-matrix 60 | pip install --user -r requirements.txt 61 | ``` 62 | 63 | 3. As your regular user, just run: `make install` in this repository directory. 64 | 65 | This installs the main python file (`main.py`) into 66 | `~/.weechat/python/` (renamed to `matrix.py`) along with the other 67 | python files it needs (from the `matrix` subdir). 68 | 69 | Note that weechat only supports Python2 OR Python3, and that setting is 70 | determined at the time that Weechat is compiled. Weechat-Matrix can work with 71 | either Python2 or Python3, but when you install dependencies you will have to 72 | take into account which version of Python your Weechat was built to use. 73 | 74 | The minimal supported python2 version is 2.7.10. 75 | 76 | The minimal supported python3 version is 3.5.4 or 3.6.1. 77 | 78 | To check the python version that weechat is using, run: 79 | 80 | /python version 81 | 82 | ## Using virtualenv 83 | If you want to install dependencies inside a virtualenv, rather than 84 | globally for your system or user, you can use a virtualenv. 85 | Weechat-Matrix will automatically use any virtualenv it finds in a 86 | directory called `venv` next to its main Python file (after resolving 87 | symlinks). Typically, this means `~/.weechat/python/venv`. 88 | 89 | To create such a virtualenv, you can use something like below. This only 90 | needs to happen once: 91 | 92 | ``` 93 | virtualenv ~/.weechat/python/venv 94 | ``` 95 | 96 | Then, activate the virtualenv: 97 | 98 | ``` 99 | . ~/.weechat/python/venv/bin/activate 100 | ``` 101 | 102 | This needs to be done whenever you want to install packages inside the 103 | virtualenv (so before running the `pip install` command documented 104 | above. 105 | 106 | 107 | Once the virtualenv is prepared in the right location, Weechat-Matrix 108 | will automatically activate it when the script is loaded. This should 109 | not affect other script, which seem to have a separate Python 110 | environment. 111 | 112 | Note that this only supports virtualenv tools that support the 113 | [`activate_this.py` way of 114 | activation](https://virtualenv.pypa.io/en/latest/userguide/#using-virtualenv-without-bin-python). 115 | This includes the `virtualenv` command, but excludes pyvenv and the 116 | Python3 `venv` module. In particular, this works if (for a typical 117 | installation of `matrix.py`) the file 118 | `~/.weechat/python/venv/bin/activate_this.py` exists. 119 | 120 | ## Run from git directly 121 | 122 | Rather than copying files into `~/.weechat` (step 3 above), it is also 123 | possible to run from a git checkout directly using symlinks. 124 | 125 | For this, you need two symlinks: 126 | 127 | ``` 128 | ln -s /path/to/weechat-matrix/main.py ~/.weechat/python/matrix.py 129 | ln -s /path/to/weechat-matrix/matrix ~/.weechat/python/matrix 130 | ``` 131 | 132 | This first link is the main python file, that can be loaded using 133 | `/script load matrix.py`. The second link is to the directory with extra 134 | python files used by the main script. This directory must be linked as 135 | `~/.weechat/python/matrix` so it ends up in the python library path and 136 | its files can be imported using e.g. `import matrix` from the main python 137 | file. 138 | 139 | Note that these symlinks are essentially the same as the files that 140 | would have been copied using `make install`. 141 | 142 | ## Uploading files 143 | 144 | Uploads are done using a helper script, which is found under 145 | [contrib/matrix_upload](https://github.com/poljar/weechat-matrix/blob/master/contrib/matrix_upload.py). 146 | We recommend you install this under your `PATH` as `matrix_upload` (without the `.py` suffix). 147 | Uploads can be done from Weechat with: `/upload `. 148 | 149 | ## Downloading encrypted files 150 | 151 | Encrypted files are displayed as an `emxc://` URI which cannot be directly 152 | opened. They can be opened in two different ways: 153 | 154 | - **In the CLI** by running the 155 | [contrib/matrix_decrypt](https://github.com/poljar/weechat-matrix/blob/master/contrib/matrix_decrypt.py) 156 | helper script. 157 | 158 | - **In the browser** by using 159 | [matrix-decryptapp](https://github.com/seirl/matrix-decryptapp). This is a 160 | static website which cannot see your data, all the decryption happens 161 | on the client side. You can either host it yourself or directly use the 162 | instance hosted on `seirl.github.io`. This weechat trigger will convert all 163 | your `emxc://` URLs into clickable https links: 164 | 165 | ``` 166 | /trigger addreplace emxc_decrypt modifier weechat_print "" ";($|[^\w/#:\[])(emxc://([^ ]+));${re:1}https://seirl.github.io/matrix-decryptapp/#${re:2};" 167 | ``` 168 | 169 | # Configuration 170 | 171 | Configuration is completed primarily through the Weechat interface. First start Weechat, and then issue the following commands: 172 | 173 | 1. Start by loading the Weechat-Matrix script: 174 | 175 | /script load matrix.py 176 | 177 | 2. Now set your username and password: 178 | 179 | /set matrix.server.matrix_org.username johndoe 180 | /set matrix.server.matrix_org.password jd_is_awesome 181 | 182 | 3. Now try to connect: 183 | 184 | /matrix connect matrix_org 185 | 186 | 4. Automatically load the script 187 | 188 | $ ln -s ../matrix.py ~/.weechat/python/autoload 189 | 190 | 5. Automatically connect to the server 191 | 192 | /set matrix.server.matrix_org.autoconnect on 193 | 194 | 6. If everything works, save the configuration 195 | 196 | /save 197 | 198 | ## For using a custom (not matrix.org) matrix server: 199 | 200 | 1. Add your custom server to the script: 201 | 202 | /matrix server add myserver myserver.org 203 | 204 | 1. Add the appropriate credentials 205 | 206 | /set matrix.server.myserver.username johndoe 207 | /set matrix.server.myserver.password jd_is_awesome 208 | 209 | 1. If everything works, save the configuration 210 | 211 | /save 212 | 213 | ## Single sign-on: 214 | 215 | Single sign-on is supported using a helper script, the script found under 216 | [contrib/matrix_sso_helper](https://github.com/poljar/weechat-matrix/blob/master/contrib/matrix_sso_helper.py) 217 | should be installed under your `PATH` as `matrix_sso_helper` (without the `.py` suffix). 218 | 219 | For single sign-on to be the preferred leave the servers username and password 220 | empty. 221 | 222 | After connecting a URL will be presented which needs to be used to perform the 223 | sign on. Please note that the helper script spawns a HTTP server which waits for 224 | the sign-on token to be passed back. This makes it necessary to do the sign on 225 | on the same host as Weechat. 226 | 227 | A hsignal is sent out when the SSO helper spawns as well, the name of the 228 | hsignal is `matrix_sso_login` and it will contain the name of the server in the 229 | `server` variable and the full URL that can be used to log in in the `url` 230 | variable. 231 | 232 | To open the login URL automatically in a browser a trigger can be added: 233 | 234 | /trigger add sso_browser hsignal matrix_sso_login "" "" "/exec -bg firefox ${url}" 235 | 236 | If signing on on the same host as Weechat is undesirable the listening port of 237 | the SSO helper should be set to a static value using the 238 | `sso_helper_listening_port` setting: 239 | 240 | /set matrix.server.myserver.sso_helper_listening_port 8443 241 | 242 | After setting the listening port the same port on the local machine can be 243 | forwarded using ssh to the remote host: 244 | 245 | ssh -L 8443:localhost:8443 example.org 246 | 247 | This forwards the local port 8443 to the localhost:8443 address on example.org. 248 | Note that it is necessary to forward the port to the localhost address on the 249 | remote host because the helper only listens on localhost. 250 | 251 | ## Bar items 252 | 253 | There are two bar items provided by this script: 254 | 255 | 1. `matrix_typing_notice` - shows the currently typing users 256 | 257 | 1. `matrix_modes` - shows room and server info (encryption status of the room, 258 | server connection status) 259 | 260 | They can be added to the weechat status bar as usual: 261 | /set weechat.bar.status.items 262 | 263 | The `matrix_modes` bar item is replicated in the already used `buffer_modes` bar 264 | item. 265 | 266 | ## Typing notifications and read receipts 267 | 268 | The sending of typing notifications and read receipts can be temporarily 269 | disabled for a given room via the `/room` command. They can also be permanently 270 | configured using standard weechat conditions settings with the following 271 | settings: 272 | 273 | 1. `matrix.network.read_markers_conditions` 274 | 1. `matrix.network.typing_notice_conditions` 275 | 276 | ## Cursor bindings 277 | 278 | While you can reply on a matrix message using the `/reply-matrix` command (see 279 | its help in weechat), weechat-matrix also adds a binding in `/cursor` mode to 280 | easily reply to a particular message. This mode can be triggered either by 281 | running `/cursor`, or by middle-clicking somewhere on the screen. See weechat's 282 | help for `/cursor`. 283 | 284 | The default binding is: 285 | 286 | /key bindctxt cursor @chat(python.matrix.*):r hsignal:matrix_cursor_reply 287 | 288 | This means that you can reply to a message in a Matrix buffer using the middle 289 | mouse button, then `r`. 290 | 291 | This binding is automatically set when the script is loaded and there is no 292 | such binding yet. If you want to use a different key than `r`, you can execute 293 | the above command with a different key in place of `r`. To use modifier keys 294 | like control and alt, use alt-k, then your wanted binding key combo, to enter 295 | weechat's representation of that key combo in the input bar. 296 | 297 | ## Navigating room buffers using go.py 298 | 299 | If you try to use the `go.py` script to navigate buffers created by 300 | weechat-matrix, `go.py` will by default use the full buffer name which does not 301 | contain a human-readable room display name but only the Matrix room ID. This is 302 | necessary so that the logger file is able to produce unique, permanent 303 | filenames for a room. 304 | 305 | However, buffers also have human-readable short names. To make `go.py` use the 306 | short names for navigation, you can run the following command: 307 | 308 | ``` 309 | /set plugins.var.python.go.short_name "on" 310 | ``` 311 | 312 | As an alternative, you can also force weechat-matrix to use human-readable 313 | names as the full buffer names by running 314 | 315 | ``` 316 | /set matrix.look.human_buffer_names on 317 | ``` 318 | 319 | Beware that you will then also need to adjust your logger setup to prevent room 320 | name conflicts from causing logger file conflicts. 321 | 322 | # Helpful Commands 323 | 324 | `/help matrix` will print information about the `/matrix` command. 325 | 326 | `/help olm` will print information about the `/olm` command that is used for 327 | device verification. 328 | 329 | `/matrix help [command]` will print information for subcommands, such as `/matrix help server` 330 | -------------------------------------------------------------------------------- /contrib/matrix_decrypt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # matrix_decrypt - Download and decrypt an encrypted attachment 3 | # from a matrix server 4 | 5 | # Copyright © 2019 Damir Jelić 6 | # 7 | # Permission to use, copy, modify, and/or distribute this software for 8 | # any purpose with or without fee is hereby granted, provided that the 9 | # above copyright notice and this permission notice appear in all copies. 10 | # 11 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 12 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 13 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 14 | # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER 15 | # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF 16 | # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 17 | # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 18 | 19 | import argparse 20 | import requests 21 | import tempfile 22 | import subprocess 23 | 24 | from urllib.parse import urlparse, parse_qs 25 | from nio.crypto import decrypt_attachment 26 | 27 | 28 | def save_file(data): 29 | """Save data to a temporary file and return its name.""" 30 | tmp_dir = tempfile.gettempdir() 31 | 32 | with tempfile.NamedTemporaryFile( 33 | prefix='plumber-', 34 | dir=tmp_dir, 35 | delete=False 36 | ) as f: 37 | f.write(data) 38 | f.flush() 39 | return f.name 40 | 41 | 42 | def main(): 43 | parser = argparse.ArgumentParser( 44 | description='Download and decrypt matrix attachments' 45 | ) 46 | parser.add_argument('url', help='the url of the attachment') 47 | parser.add_argument('file', nargs='?', help='save attachment to ') 48 | parser.add_argument('--plumber', 49 | help='program that gets called with the ' 50 | 'dowloaded file') 51 | 52 | args = parser.parse_args() 53 | url = urlparse(args.url) 54 | query = parse_qs(url.query) 55 | 56 | if not query["key"] or not query["iv"] or not query["hash"]: 57 | print("Missing decryption argument") 58 | return -1 59 | 60 | key = query["key"][0] 61 | iv = query["iv"][0] 62 | hash = query["hash"][0] 63 | 64 | http_url = "https://{}{}".format(url.netloc, url.path) 65 | 66 | request = requests.get(http_url) 67 | 68 | if not request.ok: 69 | print("Error downloading file") 70 | return -2 71 | 72 | plumber = args.plumber 73 | plaintext = decrypt_attachment(request.content, key, hash, iv) 74 | 75 | if args.file is None: 76 | file_name = save_file(plaintext) 77 | if plumber is None: 78 | plumber = "xdg-open" 79 | else: 80 | file_name = args.file 81 | open(file_name, "wb").write(plaintext) 82 | 83 | if plumber is not None: 84 | subprocess.run([plumber, file_name]) 85 | 86 | return 0 87 | 88 | 89 | if __name__ == "__main__": 90 | main() 91 | -------------------------------------------------------------------------------- /contrib/matrix_sso_helper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S python3 -u 2 | # Copyright 2019 The Matrix.org Foundation CIC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | 17 | import asyncio 18 | import argparse 19 | import socket 20 | import json 21 | from random import choice 22 | from aiohttp import web 23 | 24 | # The browsers ban some known ports, the dynamic port range doesn't contain any 25 | # banned ports, so we use that. 26 | port_range = range(49152, 65535) 27 | 28 | shutdown_task = None 29 | 30 | 31 | def to_weechat(message): 32 | print(json.dumps(message)) 33 | 34 | 35 | async def get_token(request): 36 | global shutdown_task 37 | 38 | async def shutdown(): 39 | await asyncio.sleep(1) 40 | raise KeyboardInterrupt 41 | 42 | token = request.query.get("loginToken") 43 | 44 | if not token: 45 | raise KeyboardInterrupt 46 | 47 | message = { 48 | "type": "token", 49 | "loginToken": token 50 | } 51 | 52 | # Send the token to weechat. 53 | to_weechat(message) 54 | # Initiate a shutdown. 55 | shutdown_task = asyncio.ensure_future(shutdown()) 56 | # Respond to the browser. 57 | return web.Response(text="Continuing in Weechat.") 58 | 59 | 60 | def bind_socket(port=None): 61 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 62 | 63 | if port is not None and port != 0: 64 | sock.bind(("localhost", port)) 65 | return sock 66 | 67 | while True: 68 | port = choice(port_range) 69 | 70 | try: 71 | sock.bind(("localhost", port)) 72 | except OSError: 73 | continue 74 | 75 | return sock 76 | 77 | 78 | async def wait_for_shutdown_task(_): 79 | if not shutdown_task: 80 | return 81 | 82 | try: 83 | await shutdown_task 84 | except KeyboardInterrupt: 85 | pass 86 | 87 | 88 | def main(): 89 | parser = argparse.ArgumentParser( 90 | description="Start a web server that waits for a SSO token to be " 91 | "passed with a GET request" 92 | ) 93 | parser.add_argument( 94 | "-p", "--port", 95 | help=("the port that the web server will be listening on, if 0 a " 96 | "random port should be chosen" 97 | ), 98 | type=int, 99 | default=0 100 | ) 101 | 102 | args = parser.parse_args() 103 | 104 | app = web.Application() 105 | app.add_routes([web.get('/', get_token)]) 106 | 107 | if not 0 <= args.port <= 65535: 108 | raise ValueError("Port needs to be 0-65535") 109 | 110 | try: 111 | sock = bind_socket(args.port) 112 | except OSError as e: 113 | message = { 114 | "type": "error", 115 | "message": str(e), 116 | "code": e.errno 117 | } 118 | to_weechat(message) 119 | return 120 | 121 | host, port = sock.getsockname() 122 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 123 | 124 | message = { 125 | "type": "redirectUrl", 126 | "host": host, 127 | "port": port 128 | } 129 | 130 | to_weechat(message) 131 | 132 | app.on_shutdown.append(wait_for_shutdown_task) 133 | web.run_app(app, sock=sock, handle_signals=True, print=None) 134 | 135 | 136 | if __name__ == "__main__": 137 | main() 138 | -------------------------------------------------------------------------------- /contrib/matrix_upload.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S python3 -u 2 | # Copyright © 2018 Damir Jelić 3 | # 4 | # Permission to use, copy, modify, and/or distribute this software for 5 | # any purpose with or without fee is hereby granted, provided that the 6 | # above copyright notice and this permission notice appear in all copies. 7 | # 8 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 11 | # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER 12 | # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF 13 | # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 14 | # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | 16 | 17 | import os 18 | import json 19 | import magic 20 | import requests 21 | import argparse 22 | from urllib.parse import urlparse 23 | from itertools import zip_longest 24 | import urllib3 25 | 26 | from nio import Api, UploadResponse, UploadError 27 | from nio.crypto import encrypt_attachment 28 | 29 | from json.decoder import JSONDecodeError 30 | 31 | urllib3.disable_warnings() 32 | 33 | 34 | def to_stdout(message): 35 | print(json.dumps(message), flush=True) 36 | 37 | 38 | def error(e): 39 | message = { 40 | "type": "status", 41 | "status": "error", 42 | "message": str(e) 43 | } 44 | to_stdout(message) 45 | os.sys.exit() 46 | 47 | 48 | def mime_from_file(file): 49 | try: 50 | t = magic.from_file(file, mime=True) 51 | except AttributeError: 52 | try: 53 | m = magic.open(magic.MIME) 54 | m.load() 55 | t, _ = m.file(file).split(';') 56 | except AttributeError: 57 | error('Your \'magic\' module is unsupported. ' 58 | 'Install either https://github.com/ahupp/python-magic ' 59 | 'or https://github.com/file/file/tree/master/python ' 60 | '(official \'file\' python bindings, available as the ' 61 | 'python-magic package on many distros)') 62 | 63 | raise SystemExit 64 | 65 | return t 66 | 67 | 68 | class Upload(object): 69 | def __init__(self, file, chunksize=1 << 13): 70 | self.file = file 71 | self.filename = os.path.basename(file) 72 | self.chunksize = chunksize 73 | self.totalsize = os.path.getsize(file) 74 | self.mimetype = mime_from_file(file) 75 | self.readsofar = 0 76 | 77 | def send_progress(self): 78 | message = { 79 | "type": "progress", 80 | "data": self.readsofar 81 | } 82 | to_stdout(message) 83 | 84 | def __iter__(self): 85 | with open(self.file, 'rb') as file: 86 | while True: 87 | data = file.read(self.chunksize) 88 | 89 | if not data: 90 | break 91 | 92 | self.readsofar += len(data) 93 | self.send_progress() 94 | 95 | yield data 96 | 97 | def __len__(self): 98 | return self.totalsize 99 | 100 | 101 | def chunk_bytes(iterable, n): 102 | args = [iter(iterable)] * n 103 | return ( 104 | bytes( 105 | (filter(lambda x: x is not None, chunk)) 106 | ) for chunk in zip_longest(*args) 107 | ) 108 | 109 | 110 | class EncryptedUpload(Upload): 111 | def __init__(self, file, chunksize=1 << 13): 112 | super().__init__(file, chunksize) 113 | self.source_mimetype = self.mimetype 114 | self.mimetype = "application/octet-stream" 115 | 116 | with open(self.file, "rb") as file: 117 | self.ciphertext, self.file_keys = encrypt_attachment(file.read()) 118 | 119 | def send_progress(self): 120 | message = { 121 | "type": "progress", 122 | "data": self.readsofar 123 | } 124 | to_stdout(message) 125 | 126 | def __iter__(self): 127 | for chunk in chunk_bytes(self.ciphertext, self.chunksize): 128 | self.readsofar += len(chunk) 129 | self.send_progress() 130 | yield chunk 131 | 132 | def __len__(self): 133 | return len(self.ciphertext) 134 | 135 | 136 | class IterableToFileAdapter(object): 137 | def __init__(self, iterable): 138 | self.iterator = iter(iterable) 139 | self.length = len(iterable) 140 | 141 | def read(self, size=-1): 142 | return next(self.iterator, b'') 143 | 144 | def __len__(self): 145 | return self.length 146 | 147 | 148 | def upload_process(args): 149 | file_path = os.path.expanduser(args.file) 150 | thumbnail = None 151 | 152 | try: 153 | if args.encrypt: 154 | upload = EncryptedUpload(file_path) 155 | 156 | if upload.source_mimetype.startswith("image"): 157 | # TODO create a thumbnail 158 | thumbnail = None 159 | else: 160 | upload = Upload(file_path) 161 | 162 | except (FileNotFoundError, OSError, IOError) as e: 163 | error(e) 164 | 165 | try: 166 | url = urlparse(args.homeserver) 167 | except ValueError as e: 168 | error(e) 169 | 170 | upload_url = ("https://{}".format(args.homeserver) 171 | if not url.scheme else args.homeserver) 172 | _, api_path, _ = Api.upload(args.access_token, upload.filename) 173 | upload_url += api_path 174 | 175 | headers = { 176 | "Content-type": upload.mimetype, 177 | } 178 | 179 | proxies = {} 180 | 181 | if args.proxy_address: 182 | user = args.proxy_user or "" 183 | 184 | if args.proxy_password: 185 | user += ":{}".format(args.proxy_password) 186 | 187 | if user: 188 | user += "@" 189 | 190 | proxies = { 191 | "https": "{}://{}{}:{}/".format( 192 | args.proxy_type, 193 | user, 194 | args.proxy_address, 195 | args.proxy_port 196 | ) 197 | } 198 | 199 | message = { 200 | "type": "status", 201 | "status": "started", 202 | "total": upload.totalsize, 203 | "file_name": upload.filename, 204 | } 205 | 206 | if isinstance(upload, EncryptedUpload): 207 | message["mimetype"] = upload.source_mimetype 208 | else: 209 | message["mimetype"] = upload.mimetype 210 | 211 | to_stdout(message) 212 | 213 | session = requests.Session() 214 | session.trust_env = False 215 | 216 | try: 217 | r = session.post( 218 | url=upload_url, 219 | auth=None, 220 | headers=headers, 221 | data=IterableToFileAdapter(upload), 222 | verify=(not args.insecure), 223 | proxies=proxies 224 | ) 225 | except (requests.exceptions.RequestException, OSError) as e: 226 | error(e) 227 | 228 | try: 229 | json_response = json.loads(r.content) 230 | except JSONDecodeError: 231 | error(r.content) 232 | 233 | response = UploadResponse.from_dict(json_response) 234 | 235 | if isinstance(response, UploadError): 236 | error(str(response)) 237 | 238 | message = { 239 | "type": "status", 240 | "status": "done", 241 | "url": response.content_uri 242 | } 243 | 244 | if isinstance(upload, EncryptedUpload): 245 | message["file_keys"] = upload.file_keys 246 | 247 | to_stdout(message) 248 | 249 | return 0 250 | 251 | 252 | def main(): 253 | parser = argparse.ArgumentParser( 254 | description="Encrypt and upload matrix attachments" 255 | ) 256 | parser.add_argument("file", help="the file that will be uploaded") 257 | parser.add_argument( 258 | "homeserver", 259 | type=str, 260 | help="the address of the homeserver" 261 | ) 262 | parser.add_argument( 263 | "access_token", 264 | type=str, 265 | help="the access token to use for the upload" 266 | ) 267 | parser.add_argument( 268 | "--encrypt", 269 | action="store_const", 270 | const=True, 271 | default=False, 272 | help="encrypt the file before uploading it" 273 | ) 274 | parser.add_argument( 275 | "--insecure", 276 | action="store_const", 277 | const=True, 278 | default=False, 279 | help="disable SSL certificate verification" 280 | ) 281 | parser.add_argument( 282 | "--proxy-type", 283 | choices=[ 284 | "http", 285 | "socks4", 286 | "socks5" 287 | ], 288 | default="http", 289 | help="type of the proxy that will be used to establish a connection" 290 | ) 291 | parser.add_argument( 292 | "--proxy-address", 293 | type=str, 294 | help="address of the proxy that will be used to establish a connection" 295 | ) 296 | parser.add_argument( 297 | "--proxy-port", 298 | type=int, 299 | default=8080, 300 | help="port of the proxy that will be used to establish a connection" 301 | ) 302 | parser.add_argument( 303 | "--proxy-user", 304 | type=str, 305 | help="user that will be used for authentication on the proxy" 306 | ) 307 | parser.add_argument( 308 | "--proxy-password", 309 | type=str, 310 | help="password that will be used for authentication on the proxy" 311 | ) 312 | 313 | args = parser.parse_args() 314 | upload_process(args) 315 | 316 | 317 | if __name__ == "__main__": 318 | main() 319 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Weechat Matrix Protocol Script 4 | # Copyright © 2018 Damir Jelić 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for 7 | # any purpose with or without fee is hereby granted, provided that the 8 | # above copyright notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 13 | # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER 14 | # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF 15 | # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 16 | # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | 18 | from __future__ import unicode_literals 19 | 20 | import os 21 | 22 | # See if there is a `venv` directory next to our script, and use that if 23 | # present. This first resolves symlinks, so this also works when we are 24 | # loaded through a symlink (e.g. from autoload). 25 | # See https://virtualenv.pypa.io/en/latest/userguide/#using-virtualenv-without-bin-python 26 | # This does not support pyvenv or the python3 venv module, which do not 27 | # create an activate_this.py: https://stackoverflow.com/questions/27462582 28 | activate_this = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'venv', 'bin', 'activate_this.py') 29 | if os.path.exists(activate_this): 30 | exec(open(activate_this).read(), {'__file__': activate_this}) 31 | 32 | import socket 33 | import ssl 34 | import textwrap 35 | # pylint: disable=redefined-builtin 36 | from builtins import str 37 | from itertools import chain 38 | # pylint: disable=unused-import 39 | from typing import Any, AnyStr, Deque, Dict, List, Optional, Set, Text, Tuple 40 | 41 | import logbook 42 | import json 43 | import OpenSSL.crypto as crypto 44 | from future.utils import bytes_to_native_str as n 45 | from logbook import Logger, StreamHandler 46 | 47 | try: 48 | from json.decoder import JSONDecodeError 49 | except ImportError: 50 | JSONDecodeError = ValueError # type: ignore 51 | 52 | 53 | from nio import RemoteProtocolError, RemoteTransportError, TransportType 54 | 55 | from matrix import globals as G 56 | from matrix.bar_items import ( 57 | init_bar_items, 58 | matrix_bar_item_buffer_modes, 59 | matrix_bar_item_lag, 60 | matrix_bar_item_name, 61 | matrix_bar_item_plugin, 62 | matrix_bar_nicklist_count, 63 | matrix_bar_typing_notices_cb 64 | ) 65 | from matrix.buffer import room_buffer_close_cb, room_buffer_input_cb 66 | # Weechat searches for the registered callbacks in the scope of the main script 67 | # file, import the callbacks here so weechat can find them. 68 | from matrix.commands import (hook_commands, hook_key_bindings, hook_page_up, 69 | matrix_command_buf_clear_cb, matrix_command_cb, 70 | matrix_command_pgup_cb, matrix_invite_command_cb, 71 | matrix_join_command_cb, matrix_kick_command_cb, 72 | matrix_me_command_cb, matrix_part_command_cb, 73 | matrix_redact_command_cb, matrix_topic_command_cb, 74 | matrix_olm_command_cb, matrix_devices_command_cb, 75 | matrix_room_command_cb, matrix_uploads_command_cb, 76 | matrix_upload_command_cb, matrix_send_anyways_cb, 77 | matrix_reply_command_cb, 78 | matrix_cursor_reply_signal_cb) 79 | from matrix.completion import (init_completion, matrix_command_completion_cb, 80 | matrix_debug_completion_cb, 81 | matrix_message_completion_cb, 82 | matrix_olm_device_completion_cb, 83 | matrix_olm_user_completion_cb, 84 | matrix_server_command_completion_cb, 85 | matrix_server_completion_cb, 86 | matrix_user_completion_cb, 87 | matrix_own_devices_completion_cb, 88 | matrix_room_completion_cb) 89 | from matrix.config import (MatrixConfig, config_log_category_cb, 90 | config_log_level_cb, config_server_buffer_cb, 91 | matrix_config_reload_cb, config_pgup_cb) 92 | from matrix.globals import SCRIPT_NAME, SERVERS, W 93 | from matrix.server import (MatrixServer, create_default_server, 94 | matrix_config_server_change_cb, 95 | matrix_config_server_read_cb, 96 | matrix_config_server_write_cb, matrix_timer_cb, 97 | send_cb, matrix_load_users_cb) 98 | from matrix.utf import utf8_decode 99 | from matrix.utils import server_buffer_prnt, server_buffer_set_title 100 | 101 | from matrix.uploads import UploadsBuffer, upload_cb 102 | 103 | try: 104 | from urllib.parse import urlunparse 105 | except ImportError: 106 | from urlparse import urlunparse 107 | 108 | # yapf: disable 109 | WEECHAT_SCRIPT_NAME = SCRIPT_NAME 110 | WEECHAT_SCRIPT_DESCRIPTION = "matrix chat plugin" # type: str 111 | WEECHAT_SCRIPT_AUTHOR = "Damir Jelić " # type: str 112 | WEECHAT_SCRIPT_VERSION = "0.3.0" # type: str 113 | WEECHAT_SCRIPT_LICENSE = "ISC" # type: str 114 | # yapf: enable 115 | 116 | 117 | logger = Logger("matrix-cli") 118 | 119 | 120 | def print_certificate_info(buff, sock, cert): 121 | cert_pem = ssl.DER_cert_to_PEM_cert(sock.getpeercert(True)) 122 | 123 | x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert_pem) 124 | 125 | public_key = x509.get_pubkey() 126 | 127 | key_type = ("RSA" if public_key.type() == crypto.TYPE_RSA else "DSA") 128 | key_size = str(public_key.bits()) 129 | sha256_fingerprint = x509.digest(n(b"SHA256")) 130 | sha1_fingerprint = x509.digest(n(b"SHA1")) 131 | signature_algorithm = x509.get_signature_algorithm() 132 | 133 | key_info = ("key info: {key_type} key {bits} bits, signed using " 134 | "{algo}").format( 135 | key_type=key_type, bits=key_size, 136 | algo=n(signature_algorithm)) 137 | 138 | validity_info = (" Begins on: {before}\n" 139 | " Expires on: {after}").format( 140 | before=cert["notBefore"], after=cert["notAfter"]) 141 | 142 | rdns = chain(*cert["subject"]) 143 | subject = ", ".join(["{}={}".format(name, value) for name, value in rdns]) 144 | 145 | rdns = chain(*cert["issuer"]) 146 | issuer = ", ".join(["{}={}".format(name, value) for name, value in rdns]) 147 | 148 | subject = "subject: {sub}, serial number {serial}".format( 149 | sub=subject, serial=cert["serialNumber"]) 150 | 151 | issuer = "issuer: {issuer}".format(issuer=issuer) 152 | 153 | fingerprints = (" SHA1: {}\n" 154 | " SHA256: {}").format(n(sha1_fingerprint), 155 | n(sha256_fingerprint)) 156 | 157 | wrapper = textwrap.TextWrapper( 158 | initial_indent=" - ", subsequent_indent=" ") 159 | 160 | message = ("{prefix}matrix: received certificate\n" 161 | " - certificate info:\n" 162 | "{subject}\n" 163 | "{issuer}\n" 164 | "{key_info}\n" 165 | " - period of validity:\n{validity_info}\n" 166 | " - fingerprints:\n{fingerprints}").format( 167 | prefix=W.prefix("network"), 168 | subject=wrapper.fill(subject), 169 | issuer=wrapper.fill(issuer), 170 | key_info=wrapper.fill(key_info), 171 | validity_info=validity_info, 172 | fingerprints=fingerprints) 173 | 174 | W.prnt(buff, message) 175 | 176 | 177 | def wrap_socket(server, file_descriptor): 178 | # type: (MatrixServer, int) -> None 179 | sock = None # type: socket.socket 180 | 181 | temp_socket = socket.fromfd(file_descriptor, socket.AF_INET, 182 | socket.SOCK_STREAM) 183 | 184 | # fromfd() duplicates the file descriptor, we can close the one we got from 185 | # weechat now since we use the one from our socket when calling hook_fd() 186 | os.close(file_descriptor) 187 | 188 | # For python 2.7 wrap_socket() doesn't work with sockets created from an 189 | # file descriptor because fromfd() doesn't return a wrapped socket, the bug 190 | # was fixed for python 3, more info: https://bugs.python.org/issue13942 191 | # pylint: disable=protected-access,unidiomatic-typecheck 192 | if type(temp_socket) == socket._socket.socket: 193 | # pylint: disable=no-member 194 | sock = socket._socketobject(_sock=temp_socket) 195 | else: 196 | sock = temp_socket 197 | 198 | # fromfd() duplicates the file descriptor but doesn't retain it's blocking 199 | # non-blocking attribute, so mark the socket as non-blocking even though 200 | # weechat already did that for us 201 | sock.setblocking(False) 202 | 203 | message = "{prefix}matrix: Doing SSL handshake...".format( 204 | prefix=W.prefix("network")) 205 | W.prnt(server.server_buffer, message) 206 | 207 | ssl_socket = server.ssl_context.wrap_socket( 208 | sock, do_handshake_on_connect=False, 209 | server_hostname=server.address) # type: ssl.SSLSocket 210 | 211 | server.socket = ssl_socket 212 | 213 | try_ssl_handshake(server) 214 | 215 | 216 | @utf8_decode 217 | def ssl_fd_cb(server_name, file_descriptor): 218 | server = SERVERS[server_name] 219 | 220 | if server.ssl_hook: 221 | W.unhook(server.ssl_hook) 222 | server.ssl_hook = None 223 | 224 | try_ssl_handshake(server) 225 | 226 | return W.WEECHAT_RC_OK 227 | 228 | 229 | def try_ssl_handshake(server): 230 | sock = server.socket 231 | 232 | while True: 233 | try: 234 | sock.do_handshake() 235 | 236 | cipher = sock.cipher() 237 | cipher_message = ("{prefix}matrix: Connected using {tls}, and " 238 | "{bit} bit {cipher} cipher suite.").format( 239 | prefix=W.prefix("network"), 240 | tls=cipher[1], 241 | bit=cipher[2], 242 | cipher=cipher[0]) 243 | W.prnt(server.server_buffer, cipher_message) 244 | 245 | cert = sock.getpeercert() 246 | if cert: 247 | print_certificate_info(server.server_buffer, sock, cert) 248 | 249 | finalize_connection(server) 250 | 251 | return True 252 | 253 | except ssl.SSLWantReadError: 254 | hook = W.hook_fd(server.socket.fileno(), 1, 0, 0, "ssl_fd_cb", 255 | server.name) 256 | server.ssl_hook = hook 257 | 258 | return False 259 | 260 | except ssl.SSLWantWriteError: 261 | hook = W.hook_fd(server.socket.fileno(), 0, 1, 0, "ssl_fd_cb", 262 | server.name) 263 | server.ssl_hook = hook 264 | 265 | return False 266 | 267 | except (ssl.SSLError, ssl.CertificateError, socket.error) as error: 268 | try: 269 | str_error = error.reason if error.reason else "Unknown error" 270 | except AttributeError: 271 | str_error = str(error) 272 | 273 | message = ("{prefix}Error while doing SSL handshake" 274 | ": {error}").format( 275 | prefix=W.prefix("network"), error=str_error) 276 | 277 | server_buffer_prnt(server, message) 278 | 279 | server_buffer_prnt( 280 | server, ("{prefix}matrix: disconnecting from server..." 281 | ).format(prefix=W.prefix("network"))) 282 | 283 | server.disconnect() 284 | return False 285 | 286 | 287 | @utf8_decode 288 | def receive_cb(server_name, file_descriptor): 289 | server = SERVERS[server_name] 290 | 291 | while True: 292 | try: 293 | data = server.socket.recv(4096) 294 | except ssl.SSLWantReadError: 295 | break 296 | except socket.error as error: 297 | errno = "error" + str(error.errno) + " " if error.errno else "" 298 | str_error = error.strerror if error.strerror else "Unknown error" 299 | str_error = errno + str_error 300 | 301 | message = ("{prefix}Error while reading from " 302 | "socket: {error}").format( 303 | prefix=W.prefix("network"), error=str_error) 304 | 305 | server_buffer_prnt(server, message) 306 | 307 | server_buffer_prnt( 308 | server, ("{prefix}matrix: disconnecting from server..." 309 | ).format(prefix=W.prefix("network"))) 310 | 311 | server.disconnect() 312 | 313 | return W.WEECHAT_RC_OK 314 | 315 | if not data: 316 | server_buffer_prnt( 317 | server, 318 | "{prefix}matrix: Error while reading from socket".format( 319 | prefix=W.prefix("network"))) 320 | server_buffer_prnt( 321 | server, ("{prefix}matrix: disconnecting from server..." 322 | ).format(prefix=W.prefix("network"))) 323 | 324 | server.disconnect() 325 | break 326 | 327 | try: 328 | server.client.receive(data) 329 | except (RemoteTransportError, RemoteProtocolError) as e: 330 | server.error(str(e)) 331 | server.disconnect() 332 | break 333 | 334 | response = server.client.next_response() 335 | 336 | # Check if we need to send some data back 337 | data_to_send = server.client.data_to_send() 338 | 339 | if data_to_send: 340 | server.send(data_to_send) 341 | 342 | if response: 343 | server.handle_response(response) 344 | break 345 | 346 | return W.WEECHAT_RC_OK 347 | 348 | 349 | def finalize_connection(server): 350 | hook = W.hook_fd( 351 | server.socket.fileno(), 352 | 1, 353 | 0, 354 | 0, 355 | "receive_cb", 356 | server.name 357 | ) 358 | 359 | server.fd_hook = hook 360 | server.connected = True 361 | server.connecting = False 362 | server.reconnect_delay = 0 363 | 364 | negotiated_protocol = (server.socket.selected_alpn_protocol() or 365 | server.socket.selected_npn_protocol()) 366 | 367 | if negotiated_protocol == "h2": 368 | server.transport_type = TransportType.HTTP2 369 | else: 370 | server.transport_type = TransportType.HTTP 371 | 372 | data = server.client.connect(server.transport_type) 373 | server.send(data) 374 | 375 | server.login_info() 376 | 377 | 378 | @utf8_decode 379 | def sso_login_cb(server_name, command, return_code, out, err): 380 | try: 381 | server = SERVERS[server_name] 382 | except KeyError: 383 | message = ( 384 | "{}{}: SSO callback ran, but no server for it was found.").format( 385 | W.prefix("error"), SCRIPT_NAME) 386 | W.prnt("", message) 387 | 388 | if return_code == W.WEECHAT_HOOK_PROCESS_ERROR: 389 | server.error("Error while running the matrix_sso_helper. Please " 390 | "make sure that the helper script is executable and can " 391 | "be found in your PATH.") 392 | server.sso_hook = None 393 | server.disconnect() 394 | return W.WEECHAT_RC_OK 395 | 396 | # The child process exited mark the hook as done. 397 | if return_code == 0: 398 | server.sso_hook = None 399 | 400 | if err != "": 401 | W.prnt("", "stderr: %s" % err) 402 | 403 | if out == "": 404 | return W.WEECHAT_RC_OK 405 | 406 | try: 407 | ret = json.loads(out) 408 | msgtype = ret.get("type") 409 | 410 | if msgtype == "redirectUrl": 411 | redirect_url = "http://{}:{}".format(ret["host"], ret["port"]) 412 | 413 | login_url = ( 414 | "{}/_matrix/client/r0/login/sso/redirect?redirectUrl={}" 415 | ).format(server.homeserver.geturl(), redirect_url) 416 | 417 | server.info_highlight( 418 | "The server requested a single sign-on, please open " 419 | "this URL in your browser. Note that the " 420 | "browser needs to run on the same host as Weechat.") 421 | server.info_highlight(login_url) 422 | 423 | message = { 424 | "server": server.name, 425 | "url": login_url 426 | } 427 | W.hook_hsignal_send("matrix_sso_login", message) 428 | 429 | elif msgtype == "token": 430 | token = ret["loginToken"] 431 | server.login(token=token) 432 | 433 | elif msgtype == "error": 434 | server.error("Error in the SSO helper {}".format(ret["message"])) 435 | 436 | else: 437 | server.error("Unknown SSO login message received from child " 438 | "process.") 439 | 440 | except JSONDecodeError: 441 | server.error( 442 | "Error decoding SSO login message from child process: {}".format( 443 | out 444 | )) 445 | 446 | return W.WEECHAT_RC_OK 447 | 448 | 449 | @utf8_decode 450 | def connect_cb(data, status, gnutls_rc, sock, error, ip_address): 451 | # pylint: disable=too-many-arguments,too-many-branches 452 | status_value = int(status) # type: int 453 | server = SERVERS[data] 454 | 455 | if status_value == W.WEECHAT_HOOK_CONNECT_OK: 456 | file_descriptor = int(sock) # type: int 457 | server.numeric_address = ip_address 458 | server_buffer_set_title(server) 459 | 460 | wrap_socket(server, file_descriptor) 461 | 462 | return W.WEECHAT_RC_OK 463 | 464 | elif status_value == W.WEECHAT_HOOK_CONNECT_ADDRESS_NOT_FOUND: 465 | server.error('{address} not found'.format(address=ip_address)) 466 | 467 | elif status_value == W.WEECHAT_HOOK_CONNECT_IP_ADDRESS_NOT_FOUND: 468 | server.error('IP address not found') 469 | 470 | elif status_value == W.WEECHAT_HOOK_CONNECT_CONNECTION_REFUSED: 471 | server.error('Connection refused') 472 | 473 | elif status_value == W.WEECHAT_HOOK_CONNECT_PROXY_ERROR: 474 | server.error('Proxy fails to establish connection to server') 475 | 476 | elif status_value == W.WEECHAT_HOOK_CONNECT_LOCAL_HOSTNAME_ERROR: 477 | server.error('Unable to set local hostname') 478 | 479 | elif status_value == W.WEECHAT_HOOK_CONNECT_GNUTLS_INIT_ERROR: 480 | server.error('TLS init error') 481 | 482 | elif status_value == W.WEECHAT_HOOK_CONNECT_GNUTLS_HANDSHAKE_ERROR: 483 | server.error('TLS Handshake failed') 484 | 485 | elif status_value == W.WEECHAT_HOOK_CONNECT_MEMORY_ERROR: 486 | server.error('Not enough memory') 487 | 488 | elif status_value == W.WEECHAT_HOOK_CONNECT_TIMEOUT: 489 | server.error('Timeout') 490 | 491 | elif status_value == W.WEECHAT_HOOK_CONNECT_SOCKET_ERROR: 492 | server.error('Unable to create socket') 493 | else: 494 | server.error('Unexpected error: {status}'.format(status=status_value)) 495 | 496 | server.disconnect(reconnect=True) 497 | return W.WEECHAT_RC_OK 498 | 499 | 500 | @utf8_decode 501 | def room_close_cb(data, buffer): 502 | W.prnt("", 503 | "Buffer '%s' will be closed!" % W.buffer_get_string(buffer, "name")) 504 | return W.WEECHAT_RC_OK 505 | 506 | 507 | @utf8_decode 508 | def matrix_unload_cb(): 509 | for server in SERVERS.values(): 510 | server.config.free() 511 | 512 | G.CONFIG.free() 513 | 514 | return W.WEECHAT_RC_OK 515 | 516 | 517 | def autoconnect(servers): 518 | for server in servers.values(): 519 | if server.config.autoconnect: 520 | server.connect() 521 | 522 | 523 | def debug_buffer_close_cb(data, buffer): 524 | G.CONFIG.debug_buffer = "" 525 | return W.WEECHAT_RC_OK 526 | 527 | 528 | def server_buffer_cb(server_name, buffer, input_data): 529 | message = ("{}{}: this buffer is not a room buffer!").format( 530 | W.prefix("error"), SCRIPT_NAME) 531 | W.prnt(buffer, message) 532 | return W.WEECHAT_RC_OK 533 | 534 | 535 | class WeechatHandler(StreamHandler): 536 | def __init__(self, level=logbook.NOTSET, format_string=None, filter=None, 537 | bubble=False): 538 | StreamHandler.__init__( 539 | self, 540 | object(), 541 | level, 542 | format_string, 543 | None, 544 | filter, 545 | bubble 546 | ) 547 | 548 | def write(self, item): 549 | buf = "" 550 | 551 | if G.CONFIG.network.debug_buffer: 552 | if not G.CONFIG.debug_buffer: 553 | G.CONFIG.debug_buffer = W.buffer_new( 554 | "Matrix Debug", "", "", "debug_buffer_close_cb", "") 555 | 556 | buf = G.CONFIG.debug_buffer 557 | 558 | W.prnt(buf, item) 559 | 560 | 561 | def buffer_switch_cb(_, _signal, buffer_ptr): 562 | """Do some buffer operations when we switch buffers. 563 | 564 | This function is called every time we switch a buffer. The pointer of 565 | the new buffer is given to us by weechat. 566 | 567 | If it is one of our room buffers we check if the members for the room 568 | aren't fetched and fetch them now if they aren't. 569 | 570 | Read receipts are send out from here as well. 571 | """ 572 | for server in SERVERS.values(): 573 | if buffer_ptr == server.server_buffer: 574 | return W.WEECHAT_RC_OK 575 | 576 | if buffer_ptr not in server.buffers.values(): 577 | continue 578 | 579 | room_buffer = server.find_room_from_ptr(buffer_ptr) 580 | if not room_buffer: 581 | continue 582 | 583 | last_event_id = room_buffer.last_event_id 584 | 585 | if room_buffer.should_send_read_marker: 586 | # A buffer may not have any events, in that case no event id is 587 | # here returned 588 | if last_event_id: 589 | server.room_send_read_marker( 590 | room_buffer.room.room_id, last_event_id) 591 | room_buffer.last_read_event = last_event_id 592 | 593 | if not room_buffer.members_fetched: 594 | room_id = room_buffer.room.room_id 595 | server.get_joined_members(room_id) 596 | 597 | # The buffer is empty and we are seeing it for the first time. 598 | # Let us fetch some messages from the room history so it doesn't feel so 599 | # empty. 600 | if room_buffer.first_view and room_buffer.weechat_buffer.num_lines < 10: 601 | # TODO we may want to fetch 10 - num_lines messages here for 602 | # consistency reasons. 603 | server.room_get_messages(room_buffer.room.room_id) 604 | 605 | break 606 | 607 | return W.WEECHAT_RC_OK 608 | 609 | 610 | def typing_notification_cb(data, signal, buffer_ptr): 611 | """Send out typing notifications if the user is typing. 612 | 613 | This function is called every time the input text is changed. 614 | It checks if we are on a buffer we own, and if we are sends out a typing 615 | notification if the room is configured to send them out. 616 | """ 617 | for server in SERVERS.values(): 618 | room_buffer = server.find_room_from_ptr(buffer_ptr) 619 | if room_buffer: 620 | server.room_send_typing_notice(room_buffer) 621 | return W.WEECHAT_RC_OK 622 | 623 | if buffer_ptr == server.server_buffer: 624 | return W.WEECHAT_RC_OK 625 | 626 | return W.WEECHAT_RC_OK 627 | 628 | 629 | def buffer_command_cb(data, _, command): 630 | """Override the buffer command to allow switching buffers by short name.""" 631 | command = command[7:].strip() 632 | 633 | buffer_subcommands = ["list", "add", "clear", "move", "swap", "cycle", 634 | "merge", "unmerge", "hide", "unhide", "renumber", 635 | "close", "notify", "localvar", "set", "get"] 636 | 637 | if not command: 638 | return W.WEECHAT_RC_OK 639 | 640 | command_words = command.split() 641 | 642 | if len(command_words) >= 1: 643 | if command_words[0] in buffer_subcommands: 644 | return W.WEECHAT_RC_OK 645 | 646 | elif command_words[0].startswith("*"): 647 | return W.WEECHAT_RC_OK 648 | 649 | try: 650 | int(command_words[0]) 651 | return W.WEECHAT_RC_OK 652 | except ValueError: 653 | pass 654 | 655 | room_buffers = [] 656 | 657 | for server in SERVERS.values(): 658 | room_buffers.extend(server.room_buffers.values()) 659 | 660 | sorted_buffers = sorted( 661 | room_buffers, 662 | key=lambda b: b.weechat_buffer.number 663 | ) 664 | 665 | for room_buffer in sorted_buffers: 666 | buffer = room_buffer.weechat_buffer 667 | 668 | if command in buffer.short_name: 669 | displayed = W.current_buffer() == buffer._ptr 670 | 671 | if displayed: 672 | continue 673 | 674 | W.buffer_set(buffer._ptr, 'display', '1') 675 | return W.WEECHAT_RC_OK_EAT 676 | 677 | return W.WEECHAT_RC_OK 678 | 679 | 680 | if __name__ == "__main__": 681 | if W.register(WEECHAT_SCRIPT_NAME, WEECHAT_SCRIPT_AUTHOR, 682 | WEECHAT_SCRIPT_VERSION, WEECHAT_SCRIPT_LICENSE, 683 | WEECHAT_SCRIPT_DESCRIPTION, 'matrix_unload_cb', ''): 684 | 685 | if not W.mkdir_home("matrix", 0o700): 686 | message = ("{prefix}matrix: Error creating session " 687 | "directory").format(prefix=W.prefix("error")) 688 | W.prnt("", message) 689 | 690 | handler = WeechatHandler() 691 | handler.format_string = "{record.channel}: {record.message}" 692 | handler.push_application() 693 | 694 | # TODO if this fails we should abort and unload the script. 695 | G.CONFIG = MatrixConfig() 696 | G.CONFIG.read() 697 | 698 | hook_commands() 699 | hook_key_bindings() 700 | init_bar_items() 701 | init_completion() 702 | 703 | W.hook_command_run("/buffer", "buffer_command_cb", "") 704 | W.hook_signal("buffer_switch", "buffer_switch_cb", "") 705 | W.hook_signal("input_text_changed", "typing_notification_cb", "") 706 | 707 | if not SERVERS: 708 | create_default_server(G.CONFIG) 709 | 710 | autoconnect(SERVERS) 711 | -------------------------------------------------------------------------------- /matrix/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poljar/weechat-matrix/feae9fda26ea9de98da9cd6733980a203115537e/matrix/__init__.py -------------------------------------------------------------------------------- /matrix/_weechat.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import random 3 | import string 4 | 5 | WEECHAT_BASE_COLORS = { 6 | "black": "0", 7 | "red": "1", 8 | "green": "2", 9 | "brown": "3", 10 | "blue": "4", 11 | "magenta": "5", 12 | "cyan": "6", 13 | "default": "7", 14 | "gray": "8", 15 | "lightred": "9", 16 | "lightgreen": "10", 17 | "yellow": "11", 18 | "lightblue": "12", 19 | "lightmagenta": "13", 20 | "lightcyan": "14", 21 | "white": "15" 22 | } 23 | 24 | 25 | class MockObject(object): 26 | pass 27 | 28 | class MockConfig(object): 29 | config_template = { 30 | 'debug_buffer': None, 31 | 'debug_category': None, 32 | '_ptr': None, 33 | 'read': None, 34 | 'free': None, 35 | 'page_up_hook': None, 36 | 'color': { 37 | 'error_message_bg': "", 38 | 'error_message_fg': "", 39 | 'quote_bg': "", 40 | 'quote_fg': "", 41 | 'unconfirmed_message_bg': "", 42 | 'unconfirmed_message_fg': "", 43 | 'untagged_code_bg': "", 44 | 'untagged_code_fg': "", 45 | }, 46 | 'upload_buffer': { 47 | 'display': None, 48 | 'move_line_down': None, 49 | 'move_line_up': None, 50 | 'render': None, 51 | }, 52 | 'look': { 53 | 'bar_item_typing_notice_prefix': None, 54 | 'busy_sign': None, 55 | 'code_block_margin': None, 56 | 'code_blocks': None, 57 | 'disconnect_sign': None, 58 | 'encrypted_room_sign': None, 59 | 'encryption_warning_sign': None, 60 | 'max_typing_notice_item_length': None, 61 | 'pygments_style': None, 62 | 'redactions': None, 63 | 'server_buffer': None, 64 | 'new_channel_position': None, 65 | 'markdown_input': True, 66 | }, 67 | 'network': { 68 | 'debug_buffer': None, 69 | 'debug_category': None, 70 | 'debug_level': None, 71 | 'fetch_backlog_on_pgup': None, 72 | 'lag_min_show': None, 73 | 'lag_reconnect': None, 74 | 'lazy_load_room_users': None, 75 | 'max_initial_sync_events': None, 76 | 'max_nicklist_users': None, 77 | 'print_unconfirmed_messages': None, 78 | 'read_markers_conditions': None, 79 | 'typing_notice_conditions': None, 80 | 'autoreconnect_delay_growing': None, 81 | 'autoreconnect_delay_max': None, 82 | }, 83 | } 84 | 85 | def __init__(self): 86 | for category, options in MockConfig.config_template.items(): 87 | if options: 88 | category_object = MockObject() 89 | for option, value in options.items(): 90 | setattr(category_object, option, value) 91 | else: 92 | category_object = options 93 | 94 | setattr(self, category, category_object) 95 | 96 | 97 | def color(color_name): 98 | # type: (str) -> str 99 | # yapf: disable 100 | escape_codes = [] 101 | reset_code = "0" 102 | 103 | def make_fg_color(color_code): 104 | return "38;5;{}".format(color_code) 105 | 106 | def make_bg_color(color_code): 107 | return "48;5;{}".format(color_code) 108 | 109 | attributes = { 110 | "bold": "1", 111 | "-bold": "21", 112 | "reverse": "27", 113 | "-reverse": "21", 114 | "italic": "3", 115 | "-italic": "23", 116 | "underline": "4", 117 | "-underline": "24", 118 | "reset": "0", 119 | "resetcolor": "39" 120 | } 121 | 122 | short_attributes = { 123 | "*": "1", 124 | "!": "27", 125 | "/": "3", 126 | "_": "4" 127 | } 128 | 129 | colors = color_name.split(",", 2) 130 | 131 | fg_color = colors.pop(0) 132 | 133 | bg_color = colors.pop(0) if colors else "" 134 | 135 | if fg_color in attributes: 136 | escape_codes.append(attributes[fg_color]) 137 | else: 138 | chars = list(fg_color) 139 | 140 | for char in chars: 141 | if char in short_attributes: 142 | escape_codes.append(short_attributes[char]) 143 | elif char == "|": 144 | reset_code = "" 145 | else: 146 | break 147 | 148 | stripped_color = fg_color.lstrip("*_|/!") 149 | 150 | if stripped_color in WEECHAT_BASE_COLORS: 151 | escape_codes.append( 152 | make_fg_color(WEECHAT_BASE_COLORS[stripped_color])) 153 | 154 | elif stripped_color.isdigit(): 155 | num_color = int(stripped_color) 156 | if 0 <= num_color < 256: 157 | escape_codes.append(make_fg_color(stripped_color)) 158 | 159 | if bg_color in WEECHAT_BASE_COLORS: 160 | escape_codes.append(make_bg_color(WEECHAT_BASE_COLORS[bg_color])) 161 | else: 162 | if bg_color.isdigit(): 163 | num_color = int(bg_color) 164 | if 0 <= num_color < 256: 165 | escape_codes.append(make_bg_color(bg_color)) 166 | 167 | escape_string = "\033[{}{}m".format(reset_code, ";".join(escape_codes)) 168 | 169 | return escape_string 170 | 171 | 172 | def prefix(prefix_string): 173 | prefix_to_symbol = { 174 | "error": "=!=", 175 | "network": "--", 176 | "action": "*", 177 | "join": "-->", 178 | "quit": "<--" 179 | } 180 | 181 | if prefix_string in prefix_to_symbol: 182 | return prefix_to_symbol[prefix] 183 | 184 | return "" 185 | 186 | 187 | def prnt(_, message): 188 | print(message) 189 | 190 | 191 | def prnt_date_tags(_, date, tags_string, data): 192 | message = "{} {} [{}]".format( 193 | datetime.datetime.fromtimestamp(date), 194 | data, 195 | tags_string 196 | ) 197 | print(message) 198 | 199 | 200 | def config_search_section(*_, **__): 201 | pass 202 | 203 | 204 | def config_new_option(*_, **__): 205 | pass 206 | 207 | 208 | def mkdir_home(*_, **__): 209 | return True 210 | 211 | 212 | def info_get(info, *_): 213 | if info == "nick_color_name": 214 | return random.choice(list(WEECHAT_BASE_COLORS.keys())) 215 | 216 | return "" 217 | 218 | 219 | def buffer_new(*_, **__): 220 | return "".join( 221 | random.choice(string.ascii_uppercase + string.digits) for _ in range(8) 222 | ) 223 | 224 | 225 | def buffer_set(*_, **__): 226 | return 227 | 228 | 229 | def buffer_get_string(_ptr, property): 230 | if property == "localvar_type": 231 | return "channel" 232 | return "" 233 | 234 | 235 | def buffer_get_integer(_ptr, property): 236 | return 0 237 | 238 | 239 | def current_buffer(): 240 | return 1 241 | 242 | 243 | def nicklist_add_group(*_, **__): 244 | return 245 | 246 | 247 | def nicklist_add_nick(*_, **__): 248 | return 249 | 250 | 251 | def nicklist_remove_nick(*_, **__): 252 | return 253 | 254 | 255 | def nicklist_search_nick(*args, **kwargs): 256 | return buffer_new(args, kwargs) 257 | 258 | 259 | def string_remove_color(message, _): 260 | return message 261 | -------------------------------------------------------------------------------- /matrix/bar_items.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright © 2018, 2019 Damir Jelić 4 | # 5 | # Permission to use, copy, modify, and/or distribute this software for 6 | # any purpose with or without fee is hereby granted, provided that the 7 | # above copyright notice and this permission notice appear in all copies. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 12 | # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER 13 | # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF 14 | # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 15 | # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | 17 | from __future__ import unicode_literals 18 | 19 | from . import globals as G 20 | from .globals import SERVERS, W 21 | from .utf import utf8_decode 22 | 23 | 24 | @utf8_decode 25 | def matrix_bar_item_plugin(data, item, window, buffer, extra_info): 26 | # pylint: disable=unused-argument 27 | for server in SERVERS.values(): 28 | if buffer in server.buffers.values() or buffer == server.server_buffer: 29 | return "matrix{color}/{color_fg}{name}".format( 30 | color=W.color("bar_delim"), 31 | color_fg=W.color("bar_fg"), 32 | name=server.name, 33 | ) 34 | 35 | ptr_plugin = W.buffer_get_pointer(buffer, "plugin") 36 | name = W.plugin_get_name(ptr_plugin) 37 | 38 | return name 39 | 40 | 41 | @utf8_decode 42 | def matrix_bar_item_name(data, item, window, buffer, extra_info): 43 | # pylint: disable=unused-argument 44 | for server in SERVERS.values(): 45 | if buffer in server.buffers.values(): 46 | color = ( 47 | "status_name_ssl" 48 | if server.ssl_context.check_hostname 49 | else "status_name" 50 | ) 51 | 52 | room_buffer = server.find_room_from_ptr(buffer) 53 | room = room_buffer.room 54 | 55 | return "{color}{name}".format( 56 | color=W.color(color), name=room.display_name 57 | ) 58 | 59 | if buffer == server.server_buffer: 60 | color = ( 61 | "status_name_ssl" 62 | if server.ssl_context.check_hostname 63 | else "status_name" 64 | ) 65 | 66 | return "{color}server{del_color}[{color}{name}{del_color}]".format( 67 | color=W.color(color), 68 | del_color=W.color("bar_delim"), 69 | name=server.name, 70 | ) 71 | 72 | name = W.buffer_get_string(buffer, "name") 73 | 74 | return "{}{}".format(W.color("status_name"), name) 75 | 76 | 77 | @utf8_decode 78 | def matrix_bar_item_lag(data, item, window, buffer, extra_info): 79 | # pylint: disable=unused-argument 80 | for server in SERVERS.values(): 81 | if buffer in server.buffers.values() or buffer == server.server_buffer: 82 | if server.lag >= G.CONFIG.network.lag_min_show: 83 | color = W.color("irc.color.item_lag_counting") 84 | if server.lag_done: 85 | color = W.color("irc.color.item_lag_finished") 86 | 87 | lag = "{0:.3f}" if round(server.lag) < 1000 else "{0:.0f}" 88 | lag_string = "Lag: {color}{lag}{ncolor}".format( 89 | lag=lag.format((server.lag / 1000)), 90 | color=color, 91 | ncolor=W.color("reset"), 92 | ) 93 | return lag_string 94 | return "" 95 | 96 | return "" 97 | 98 | 99 | @utf8_decode 100 | def matrix_bar_item_buffer_modes(data, item, window, buffer, extra_info): 101 | # pylint: disable=unused-argument 102 | for server in SERVERS.values(): 103 | if buffer in server.buffers.values(): 104 | room_buffer = server.find_room_from_ptr(buffer) 105 | room = room_buffer.room 106 | modes = [] 107 | 108 | if room.encrypted: 109 | modes.append(G.CONFIG.look.encrypted_room_sign) 110 | 111 | if (server.client 112 | and server.client.room_contains_unverified(room.room_id)): 113 | modes.append(G.CONFIG.look.encryption_warning_sign) 114 | 115 | if not server.connected or not server.client.logged_in: 116 | modes.append(G.CONFIG.look.disconnect_sign) 117 | 118 | if room_buffer.backlog_pending or server.busy: 119 | modes.append(G.CONFIG.look.busy_sign) 120 | 121 | return "".join(modes) 122 | 123 | return "" 124 | 125 | 126 | @utf8_decode 127 | def matrix_bar_nicklist_count(data, item, window, buffer, extra_info): 128 | # pylint: disable=unused-argument 129 | color = W.color("status_nicklist_count") 130 | 131 | for server in SERVERS.values(): 132 | if buffer in server.buffers.values(): 133 | room_buffer = server.find_room_from_ptr(buffer) 134 | room = room_buffer.room 135 | return "{}{}".format(color, room.member_count) 136 | 137 | nicklist_enabled = bool(W.buffer_get_integer(buffer, "nicklist")) 138 | 139 | if nicklist_enabled: 140 | nick_count = W.buffer_get_integer(buffer, "nicklist_visible_count") 141 | return "{}{}".format(color, nick_count) 142 | 143 | return "" 144 | 145 | 146 | @utf8_decode 147 | def matrix_bar_typing_notices_cb(data, item, window, buffer, extra_info): 148 | """Update a status bar item showing users currently typing. 149 | This function is called by weechat every time a buffer is switched or 150 | W.bar_item_update() is explicitly called. The bar item shows 151 | currently typing users for the current buffer.""" 152 | # pylint: disable=unused-argument 153 | for server in SERVERS.values(): 154 | if buffer in server.buffers.values(): 155 | room_buffer = server.find_room_from_ptr(buffer) 156 | room = room_buffer.room 157 | 158 | if room.typing_users: 159 | nicks = [] 160 | 161 | for user_id in room.typing_users: 162 | if user_id == room.own_user_id: 163 | continue 164 | 165 | nick = room_buffer.displayed_nicks.get(user_id, user_id) 166 | nicks.append(nick) 167 | 168 | if not nicks: 169 | return "" 170 | 171 | msg = "{}{}".format( 172 | G.CONFIG.look.bar_item_typing_notice_prefix, 173 | ", ".join(sorted(nicks)) 174 | ) 175 | 176 | max_len = G.CONFIG.look.max_typing_notice_item_length 177 | if len(msg) > max_len: 178 | msg[:max_len - 3] + "..." 179 | 180 | return msg 181 | 182 | return "" 183 | 184 | return "" 185 | 186 | 187 | def init_bar_items(): 188 | W.bar_item_new("(extra)buffer_plugin", "matrix_bar_item_plugin", "") 189 | W.bar_item_new("(extra)buffer_name", "matrix_bar_item_name", "") 190 | W.bar_item_new("(extra)lag", "matrix_bar_item_lag", "") 191 | W.bar_item_new( 192 | "(extra)buffer_nicklist_count", 193 | "matrix_bar_nicklist_count", 194 | "" 195 | ) 196 | W.bar_item_new( 197 | "(extra)matrix_typing_notice", 198 | "matrix_bar_typing_notices_cb", 199 | "" 200 | ) 201 | W.bar_item_new("(extra)buffer_modes", "matrix_bar_item_buffer_modes", "") 202 | W.bar_item_new("(extra)matrix_modes", "matrix_bar_item_buffer_modes", "") 203 | -------------------------------------------------------------------------------- /matrix/colors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright © 2008 Nicholas Marriott 4 | # Copyright © 2016 Avi Halachmi 5 | # Copyright © 2018, 2019 Damir Jelić 6 | # Copyright © 2018, 2019 Denis Kasak 7 | # 8 | # Permission to use, copy, modify, and/or distribute this software for 9 | # any purpose with or without fee is hereby granted, provided that the 10 | # above copyright notice and this permission notice appear in all copies. 11 | # 12 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 13 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 14 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 15 | # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER 16 | # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF 17 | # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 18 | # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 19 | 20 | from __future__ import unicode_literals 21 | 22 | import html 23 | import re 24 | import textwrap 25 | 26 | # pylint: disable=redefined-builtin 27 | from builtins import str 28 | from collections import namedtuple 29 | from typing import Dict, List, Optional, Union 30 | 31 | import webcolors 32 | from pygments import highlight 33 | from pygments.formatter import Formatter, get_style_by_name 34 | from pygments.lexers import get_lexer_by_name 35 | from pygments.util import ClassNotFound 36 | 37 | from . import globals as G 38 | from .globals import W 39 | from .utils import (string_strikethrough, 40 | string_color_and_reset, 41 | color_pair, 42 | text_block, 43 | colored_text_block) 44 | 45 | try: 46 | from HTMLParser import HTMLParser 47 | except ImportError: 48 | from html.parser import HTMLParser 49 | 50 | 51 | class FormattedString: 52 | __slots__ = ("text", "attributes") 53 | 54 | def __init__(self, text, attributes): 55 | self.attributes = DEFAULT_ATTRIBUTES.copy() 56 | self.attributes.update(attributes) 57 | self.text = text 58 | 59 | 60 | class Formatted(object): 61 | def __init__(self, substrings): 62 | # type: (List[FormattedString]) -> None 63 | self.substrings = substrings 64 | 65 | def textwrapper(self, width, colors): 66 | return textwrap.TextWrapper( 67 | width=width, 68 | initial_indent="{}> ".format(W.color(colors)), 69 | subsequent_indent="{}> ".format(W.color(colors)), 70 | ) 71 | 72 | def is_formatted(self): 73 | # type: (Formatted) -> bool 74 | for string in self.substrings: 75 | if string.attributes != DEFAULT_ATTRIBUTES: 76 | return True 77 | return False 78 | 79 | # TODO reverse video 80 | @classmethod 81 | def from_input_line(cls, line): 82 | # type: (str) -> Formatted 83 | """Parses the weechat input line and produces formatted strings that 84 | can be later converted to HTML or to a string for weechat's print 85 | functions 86 | """ 87 | text = "" # type: str 88 | substrings = [] # type: List[FormattedString] 89 | attributes = DEFAULT_ATTRIBUTES.copy() 90 | 91 | # If this is false, only IRC formatting characters will be parsed. 92 | do_markdown = G.CONFIG.look.markdown_input 93 | 94 | # Disallow backticks in URLs so that code blocks are unaffected by the 95 | # URL handling 96 | url_regex = r"\b[a-z]+://[^\s`]+" 97 | 98 | # Escaped things are not markdown delimiters, so substitute them away 99 | # when (quickly) looking for the last delimiters in the line. 100 | # Additionally, URLs are ignored for the purposes of markdown 101 | # delimiters. 102 | # Note that the replacement needs to be the same length as the original 103 | # for the indices to be correct. 104 | escaped_masked = re.sub( 105 | r"\\[\\*_`]|(?:" + url_regex + ")", 106 | lambda m: "a" * len(m.group(0)), 107 | line 108 | ) 109 | 110 | def last_match_index(regex, offset_in_match): 111 | matches = list(re.finditer(regex, escaped_masked)) 112 | return matches[-1].span()[0] + offset_in_match if matches else -1 113 | 114 | # 'needs_word': whether the wrapper must surround words, for example 115 | # '*italic*' and not '* not-italic *'. 116 | # 'validate': whether it can occur within the current attributes 117 | wrappers = { 118 | "**": { 119 | "key": "bold", 120 | "last_index": last_match_index(r"\S\*\*", 1), 121 | "needs_word": True, 122 | "validate": lambda attrs: not attrs["code"], 123 | }, 124 | "*": { 125 | "key": "italic", 126 | "last_index": last_match_index(r"\S\*($|[^*])", 1), 127 | "needs_word": True, 128 | "validate": lambda attrs: not attrs["code"], 129 | }, 130 | "_": { 131 | "key": "italic", 132 | "last_index": last_match_index(r"\S_", 1), 133 | "needs_word": True, 134 | "validate": lambda attrs: not attrs["code"], 135 | }, 136 | "`": { 137 | "key": "code", 138 | "last_index": last_match_index(r"`", 0), 139 | "needs_word": False, 140 | "validate": lambda attrs: True, 141 | } 142 | } 143 | wrapper_init_chars = set(k[0] for k in wrappers.keys()) 144 | wrapper_max_len = max(len(k) for k in wrappers.keys()) 145 | 146 | irc_toggles = { 147 | "\x02": "bold", 148 | "\x1D": "italic", 149 | "\x1F": "underline", 150 | } 151 | 152 | # Characters that consume a prefixed backslash 153 | escapable_chars = wrapper_init_chars.copy() 154 | escapable_chars.add("\\") 155 | 156 | # Collect URL spans 157 | url_spans = [m.span() for m in re.finditer(url_regex, line)] 158 | url_spans.reverse() # we'll be popping from the end 159 | 160 | # Whether we are currently in a URL 161 | in_url = False 162 | 163 | i = 0 164 | while i < len(line): 165 | # Update the 'in_url' flag. The first condition is not a while loop 166 | # because URLs must contain '://', ensuring that we will not skip 2 167 | # URLs in one iteration. 168 | if url_spans and i >= url_spans[-1][1]: 169 | in_url = False 170 | url_spans.pop() 171 | if url_spans and i >= url_spans[-1][0]: 172 | in_url = True 173 | 174 | # Markdown escape 175 | if do_markdown and \ 176 | i + 1 < len(line) and line[i] == "\\" \ 177 | and (line[i + 1] in escapable_chars 178 | if not attributes["code"] 179 | else line[i + 1] == "`") \ 180 | and not in_url: 181 | text += line[i + 1] 182 | i = i + 2 183 | 184 | # IRC bold/italic/underline 185 | elif line[i] in irc_toggles and not attributes["code"]: 186 | if text: 187 | substrings.append(FormattedString(text, attributes.copy())) 188 | text = "" 189 | key = irc_toggles[line[i]] 190 | attributes[key] = not attributes[key] 191 | i = i + 1 192 | 193 | # IRC reset 194 | elif line[i] == "\x0F" and not attributes["code"]: 195 | if text: 196 | substrings.append(FormattedString(text, attributes.copy())) 197 | text = "" 198 | # Reset all the attributes 199 | attributes = DEFAULT_ATTRIBUTES.copy() 200 | i = i + 1 201 | 202 | # IRC color 203 | elif line[i] == "\x03" and not attributes["code"]: 204 | if text: 205 | substrings.append(FormattedString(text, attributes.copy())) 206 | text = "" 207 | i = i + 1 208 | 209 | # check if it's a valid color, add it to the attributes 210 | if line[i].isdigit(): 211 | color_string = line[i] 212 | i = i + 1 213 | 214 | if line[i].isdigit(): 215 | if color_string == "0": 216 | color_string = line[i] 217 | else: 218 | color_string = color_string + line[i] 219 | i = i + 1 220 | 221 | attributes["fgcolor"] = color_line_to_weechat(color_string) 222 | else: 223 | attributes["fgcolor"] = None 224 | 225 | # check if we have a background color 226 | if line[i] == "," and line[i + 1].isdigit(): 227 | color_string = line[i + 1] 228 | i = i + 2 229 | 230 | if line[i].isdigit(): 231 | if color_string == "0": 232 | color_string = line[i] 233 | else: 234 | color_string = color_string + line[i] 235 | i = i + 1 236 | 237 | attributes["bgcolor"] = color_line_to_weechat(color_string) 238 | else: 239 | attributes["bgcolor"] = None 240 | 241 | # Markdown wrapper (emphasis/bold/code) 242 | elif do_markdown and line[i] in wrapper_init_chars and not in_url: 243 | for l in range(wrapper_max_len, 0, -1): 244 | if i + l <= len(line) and line[i : i + l] in wrappers: 245 | descriptor = wrappers[line[i : i + l]] 246 | 247 | if not descriptor["validate"](attributes): 248 | continue 249 | 250 | if attributes[descriptor["key"]]: 251 | # needs_word wrappers can only be turned off if 252 | # preceded by non-whitespace 253 | if (i >= 1 and not line[i - 1].isspace()) \ 254 | or not descriptor["needs_word"]: 255 | if text: 256 | # strip leading and trailing spaces and 257 | # compress consecutive spaces in inline 258 | # code blocks 259 | if descriptor["key"] == "code": 260 | text = re.sub(r"\s+", " ", text.strip()) 261 | substrings.append( 262 | FormattedString(text, attributes.copy())) 263 | text = "" 264 | attributes[descriptor["key"]] = False 265 | i = i + l 266 | else: 267 | text = text + line[i : i + l] 268 | i = i + l 269 | 270 | # Must have a chance of closing this, and needs_word 271 | # wrappers must be followed by non-whitespace 272 | elif descriptor["last_index"] >= i + l and \ 273 | (not line[i + l].isspace() or \ 274 | not descriptor["needs_word"]): 275 | if text: 276 | substrings.append( 277 | FormattedString(text, attributes.copy())) 278 | text = "" 279 | attributes[descriptor["key"]] = True 280 | i = i + l 281 | 282 | else: 283 | text = text + line[i : i + l] 284 | i = i + l 285 | 286 | break 287 | 288 | else: 289 | # No wrapper matched here (NOTE: cannot happen since all 290 | # wrapper prefixes are also wrappers, but for completeness' 291 | # sake) 292 | text = text + line[i] 293 | i = i + 1 294 | 295 | # Normal text 296 | else: 297 | text = text + line[i] 298 | i = i + 1 299 | 300 | if text: 301 | substrings.append(FormattedString(text, attributes)) 302 | 303 | return cls(substrings) 304 | 305 | @classmethod 306 | def from_html(cls, html): 307 | # type: (str) -> Formatted 308 | parser = MatrixHtmlParser() 309 | parser.feed(html) 310 | return cls(parser.get_substrings()) 311 | 312 | def to_html(self): 313 | def add_attribute(string, name, value): 314 | if name == "bold" and value: 315 | return "{bold_on}{text}{bold_off}".format( 316 | bold_on="", text=string, bold_off="" 317 | ) 318 | if name == "italic" and value: 319 | return "{italic_on}{text}{italic_off}".format( 320 | italic_on="", text=string, italic_off="" 321 | ) 322 | if name == "underline" and value: 323 | return "{underline_on}{text}{underline_off}".format( 324 | underline_on="", text=string, underline_off="" 325 | ) 326 | if name == "strikethrough" and value: 327 | return "{strike_on}{text}{strike_off}".format( 328 | strike_on="", text=string, strike_off="" 329 | ) 330 | if name == "quote" and value: 331 | return "{quote_on}{text}{quote_off}".format( 332 | quote_on="
", 333 | text=string, 334 | quote_off="
", 335 | ) 336 | if name == "code" and value: 337 | return "{code_on}{text}{code_off}".format( 338 | code_on="", text=string, code_off="" 339 | ) 340 | 341 | return string 342 | 343 | def add_color(string, fgcolor, bgcolor): 344 | fgcolor_string = "" 345 | bgcolor_string = "" 346 | 347 | if fgcolor: 348 | fgcolor_string = " data-mx-color={}".format( 349 | color_weechat_to_html(fgcolor) 350 | ) 351 | 352 | if bgcolor: 353 | bgcolor_string = " data-mx-bg-color={}".format( 354 | color_weechat_to_html(bgcolor) 355 | ) 356 | 357 | return "{color_on}{text}{color_off}".format( 358 | color_on="".format( 359 | fg=fgcolor_string, 360 | bg=bgcolor_string 361 | ), 362 | text=string, 363 | color_off="", 364 | ) 365 | 366 | def format_string(formatted_string): 367 | text = formatted_string.text 368 | attributes = formatted_string.attributes.copy() 369 | 370 | # Escape HTML tag characters 371 | text = text.replace("&", "&") \ 372 | .replace("<", "<") \ 373 | .replace(">", ">") 374 | 375 | if attributes["code"]: 376 | if attributes["preformatted"]: 377 | # XXX: This can't really happen since there's no way of 378 | # creating preformatted code blocks in weechat (because 379 | # there is not multiline input), but I'm creating this 380 | # branch as a note that it should be handled once we do 381 | # implement them. 382 | pass 383 | else: 384 | text = add_attribute(text, "code", True) 385 | attributes.pop("code") 386 | 387 | if attributes["fgcolor"] or attributes["bgcolor"]: 388 | text = add_color( 389 | text, 390 | attributes["fgcolor"], 391 | attributes["bgcolor"] 392 | ) 393 | 394 | if attributes["fgcolor"]: 395 | attributes.pop("fgcolor") 396 | 397 | if attributes["bgcolor"]: 398 | attributes.pop("bgcolor") 399 | 400 | for key, value in attributes.items(): 401 | text = add_attribute(text, key, value) 402 | 403 | return text 404 | 405 | html_string = map(format_string, self.substrings) 406 | return "".join(html_string) 407 | 408 | # TODO do we want at least some formatting using unicode 409 | # (strikethrough, quotes)? 410 | def to_plain(self): 411 | # type: () -> str 412 | def strip_atribute(string, _, __): 413 | return string 414 | 415 | def format_string(formatted_string): 416 | text = formatted_string.text 417 | attributes = formatted_string.attributes 418 | 419 | for key, value in attributes.items(): 420 | text = strip_atribute(text, key, value) 421 | return text 422 | 423 | plain_string = map(format_string, self.substrings) 424 | return "".join(plain_string) 425 | 426 | def to_weechat(self): 427 | def add_attribute(string, name, value, attributes): 428 | if not value: 429 | return string 430 | elif name == "bold": 431 | return "{bold_on}{text}{bold_off}".format( 432 | bold_on=W.color("bold"), 433 | text=string, 434 | bold_off=W.color("-bold"), 435 | ) 436 | elif name == "italic": 437 | return "{italic_on}{text}{italic_off}".format( 438 | italic_on=W.color("italic"), 439 | text=string, 440 | italic_off=W.color("-italic"), 441 | ) 442 | elif name == "underline": 443 | return "{underline_on}{text}{underline_off}".format( 444 | underline_on=W.color("underline"), 445 | text=string, 446 | underline_off=W.color("-underline"), 447 | ) 448 | elif name == "strikethrough": 449 | return string_strikethrough(string) 450 | elif name == "quote": 451 | quote_pair = color_pair(G.CONFIG.color.quote_fg, 452 | G.CONFIG.color.quote_bg) 453 | 454 | # Remove leading and trailing newlines; Riot sends an extra 455 | # quoted "\n" when a user quotes a message. 456 | string = string.strip("\n") 457 | if len(string) == 0: 458 | return string 459 | 460 | if G.CONFIG.look.quote_wrap >= 0: 461 | wrapper = self.textwrapper(G.CONFIG.look.quote_wrap, quote_pair) 462 | return wrapper.fill(W.string_remove_color(string, "")) 463 | else: 464 | # Don't wrap, just add quote markers to all lines 465 | return "{color_on}{text}{color_off}".format( 466 | color_on=W.color(quote_pair), 467 | text="> " + W.string_remove_color(string.replace("\n", "\n> "), ""), 468 | color_off=W.color("resetcolor") 469 | ) 470 | elif name == "code": 471 | code_color_pair = color_pair( 472 | G.CONFIG.color.untagged_code_fg, 473 | G.CONFIG.color.untagged_code_bg 474 | ) 475 | 476 | margin = G.CONFIG.look.code_block_margin 477 | 478 | if attributes["preformatted"]: 479 | # code block 480 | 481 | try: 482 | lexer = get_lexer_by_name(value) 483 | except ClassNotFound: 484 | if G.CONFIG.look.code_blocks: 485 | return colored_text_block( 486 | string, 487 | margin=margin, 488 | color_pair=code_color_pair) 489 | else: 490 | return string_color_and_reset(string, 491 | code_color_pair) 492 | 493 | try: 494 | style = get_style_by_name(G.CONFIG.look.pygments_style) 495 | except ClassNotFound: 496 | style = "native" 497 | 498 | if G.CONFIG.look.code_blocks: 499 | code_block = text_block(string, margin=margin) 500 | else: 501 | code_block = string 502 | 503 | # highlight adds a newline to the end of the string, remove 504 | # it from the output 505 | highlighted_code = highlight( 506 | code_block, 507 | lexer, 508 | WeechatFormatter(style=style) 509 | ).rstrip() 510 | 511 | return highlighted_code 512 | else: 513 | return string_color_and_reset(string, code_color_pair) 514 | elif name == "fgcolor": 515 | return "{color_on}{text}{color_off}".format( 516 | color_on=W.color(value), 517 | text=string, 518 | color_off=W.color("resetcolor"), 519 | ) 520 | elif name == "bgcolor": 521 | return "{color_on}{text}{color_off}".format( 522 | color_on=W.color("," + value), 523 | text=string, 524 | color_off=W.color("resetcolor"), 525 | ) 526 | else: 527 | return string 528 | 529 | def format_string(formatted_string): 530 | text = formatted_string.text 531 | attributes = formatted_string.attributes 532 | 533 | # We need to handle strikethrough first, since doing 534 | # a strikethrough followed by other attributes succeeds in the 535 | # terminal, but doing it the other way around results in garbage. 536 | if "strikethrough" in attributes: 537 | text = add_attribute( 538 | text, 539 | "strikethrough", 540 | attributes["strikethrough"], 541 | attributes 542 | ) 543 | attributes.pop("strikethrough") 544 | 545 | def indent(text, prefix): 546 | return prefix + text.replace("\n", "\n{}".format(prefix)) 547 | 548 | for key, value in attributes.items(): 549 | if not value: 550 | continue 551 | 552 | # Don't use textwrap to quote the code 553 | if key == "quote" and attributes["code"]: 554 | continue 555 | 556 | # Reflow inline code blocks 557 | if key == "code" and not attributes["preformatted"]: 558 | text = text.strip().replace('\n', ' ') 559 | 560 | text = add_attribute(text, key, value, attributes) 561 | 562 | # If we're quoted code add quotation marks now. 563 | if key == "code" and attributes["quote"]: 564 | fg = G.CONFIG.color.quote_fg 565 | bg = G.CONFIG.color.quote_bg 566 | text = indent( 567 | text, 568 | string_color_and_reset(">", color_pair(fg, bg)) + " ", 569 | ) 570 | 571 | # If we're code don't remove multiple newlines blindly 572 | if attributes["code"]: 573 | return text 574 | return re.sub(r"\n+", "\n", text) 575 | 576 | weechat_strings = map(format_string, self.substrings) 577 | 578 | # Remove duplicate \n elements from the list 579 | strings = [] 580 | for string in weechat_strings: 581 | if len(strings) == 0 or string != "\n" or string != strings[-1]: 582 | strings.append(string) 583 | 584 | return "".join(strings).strip() 585 | 586 | 587 | DEFAULT_ATTRIBUTES = { 588 | "bold": False, 589 | "italic": False, 590 | "underline": False, 591 | "strikethrough": False, 592 | "preformatted": False, 593 | "quote": False, 594 | "code": None, 595 | "fgcolor": None, 596 | "bgcolor": None, 597 | } # type: Dict[str, Union[bool, Optional[str]]] 598 | 599 | 600 | class MatrixHtmlParser(HTMLParser): 601 | # TODO bullets 602 | def __init__(self): 603 | HTMLParser.__init__(self) 604 | self.text = "" # type: str 605 | self.substrings = [] # type: List[FormattedString] 606 | self.attributes = DEFAULT_ATTRIBUTES.copy() 607 | 608 | def unescape(self, text): 609 | """Shim to unescape HTML in both Python 2 and 3. 610 | 611 | The instance method was deprecated in Python 3 and html.unescape 612 | doesn't exist in Python 2 so this is needed. 613 | """ 614 | try: 615 | return html.unescape(text) 616 | except AttributeError: 617 | return HTMLParser.unescape(self, text) 618 | 619 | def add_substring(self, text, attrs): 620 | fmt_string = FormattedString(text, attrs) 621 | self.substrings.append(fmt_string) 622 | 623 | def _toggle_attribute(self, attribute): 624 | if self.text: 625 | self.add_substring(self.text, self.attributes.copy()) 626 | self.text = "" 627 | self.attributes[attribute] = not self.attributes[attribute] 628 | 629 | def handle_starttag(self, tag, attrs): 630 | if tag == "strong": 631 | self._toggle_attribute("bold") 632 | elif tag == "em": 633 | self._toggle_attribute("italic") 634 | elif tag == "u": 635 | self._toggle_attribute("underline") 636 | elif tag == "del": 637 | self._toggle_attribute("strikethrough") 638 | elif tag == "blockquote": 639 | self._toggle_attribute("quote") 640 | elif tag == "pre": 641 | self._toggle_attribute("preformatted") 642 | elif tag == "code": 643 | lang = None 644 | 645 | for key, value in attrs: 646 | if key == "class": 647 | if value.startswith("language-"): 648 | lang = value.split("-", 1)[1] 649 | 650 | lang = lang or "unknown" 651 | 652 | if self.text: 653 | self.add_substring(self.text, self.attributes.copy()) 654 | self.text = "" 655 | self.attributes["code"] = lang 656 | elif tag == "p": 657 | if self.text: 658 | self.add_substring(self.text, self.attributes.copy()) 659 | self.text = "\n" 660 | self.add_substring(self.text, DEFAULT_ATTRIBUTES.copy()) 661 | self.text = "" 662 | elif tag == "br": 663 | if self.text: 664 | self.add_substring(self.text, self.attributes.copy()) 665 | self.text = "\n" 666 | self.add_substring(self.text, DEFAULT_ATTRIBUTES.copy()) 667 | self.text = "" 668 | elif tag == "font": 669 | for key, value in attrs: 670 | if key in ["data-mx-color", "color"]: 671 | color = color_html_to_weechat(value) 672 | 673 | if not color: 674 | continue 675 | 676 | if self.text: 677 | self.add_substring(self.text, self.attributes.copy()) 678 | self.text = "" 679 | self.attributes["fgcolor"] = color 680 | 681 | elif key in ["data-mx-bg-color"]: 682 | color = color_html_to_weechat(value) 683 | if not color: 684 | continue 685 | 686 | if self.text: 687 | self.add_substring(self.text, self.attributes.copy()) 688 | self.text = "" 689 | self.attributes["bgcolor"] = color 690 | 691 | else: 692 | pass 693 | 694 | def handle_endtag(self, tag): 695 | if tag == "strong": 696 | self._toggle_attribute("bold") 697 | elif tag == "em": 698 | self._toggle_attribute("italic") 699 | elif tag == "u": 700 | self._toggle_attribute("underline") 701 | elif tag == "del": 702 | self._toggle_attribute("strikethrough") 703 | elif tag == "pre": 704 | self._toggle_attribute("preformatted") 705 | elif tag == "code": 706 | if self.text: 707 | self.add_substring(self.text, self.attributes.copy()) 708 | self.text = "" 709 | self.attributes["code"] = None 710 | elif tag == "blockquote": 711 | self._toggle_attribute("quote") 712 | self.text = "\n" 713 | self.add_substring(self.text, DEFAULT_ATTRIBUTES.copy()) 714 | self.text = "" 715 | elif tag == "font": 716 | if self.text: 717 | self.add_substring(self.text, self.attributes.copy()) 718 | self.text = "" 719 | self.attributes["fgcolor"] = None 720 | else: 721 | pass 722 | 723 | def handle_data(self, data): 724 | self.text += data 725 | 726 | def handle_entityref(self, name): 727 | self.text += self.unescape("&{};".format(name)) 728 | 729 | def handle_charref(self, name): 730 | self.text += self.unescape("&#{};".format(name)) 731 | 732 | def get_substrings(self): 733 | if self.text: 734 | self.add_substring(self.text, self.attributes.copy()) 735 | 736 | return self.substrings 737 | 738 | 739 | def color_line_to_weechat(color_string): 740 | # type: (str) -> str 741 | line_colors = { 742 | "0": "white", 743 | "1": "black", 744 | "2": "blue", 745 | "3": "green", 746 | "4": "lightred", 747 | "5": "red", 748 | "6": "magenta", 749 | "7": "brown", 750 | "8": "yellow", 751 | "9": "lightgreen", 752 | "10": "cyan", 753 | "11": "lightcyan", 754 | "12": "lightblue", 755 | "13": "lightmagenta", 756 | "14": "darkgray", 757 | "15": "gray", 758 | "16": "52", 759 | "17": "94", 760 | "18": "100", 761 | "19": "58", 762 | "20": "22", 763 | "21": "29", 764 | "22": "23", 765 | "23": "24", 766 | "24": "17", 767 | "25": "54", 768 | "26": "53", 769 | "27": "89", 770 | "28": "88", 771 | "29": "130", 772 | "30": "142", 773 | "31": "64", 774 | "32": "28", 775 | "33": "35", 776 | "34": "30", 777 | "35": "25", 778 | "36": "18", 779 | "37": "91", 780 | "38": "90", 781 | "39": "125", 782 | "40": "124", 783 | "41": "166", 784 | "42": "184", 785 | "43": "106", 786 | "44": "34", 787 | "45": "49", 788 | "46": "37", 789 | "47": "33", 790 | "48": "19", 791 | "49": "129", 792 | "50": "127", 793 | "51": "161", 794 | "52": "196", 795 | "53": "208", 796 | "54": "226", 797 | "55": "154", 798 | "56": "46", 799 | "57": "86", 800 | "58": "51", 801 | "59": "75", 802 | "60": "21", 803 | "61": "171", 804 | "62": "201", 805 | "63": "198", 806 | "64": "203", 807 | "65": "215", 808 | "66": "227", 809 | "67": "191", 810 | "68": "83", 811 | "69": "122", 812 | "70": "87", 813 | "71": "111", 814 | "72": "63", 815 | "73": "177", 816 | "74": "207", 817 | "75": "205", 818 | "76": "217", 819 | "77": "223", 820 | "78": "229", 821 | "79": "193", 822 | "80": "157", 823 | "81": "158", 824 | "82": "159", 825 | "83": "153", 826 | "84": "147", 827 | "85": "183", 828 | "86": "219", 829 | "87": "212", 830 | "88": "16", 831 | "89": "233", 832 | "90": "235", 833 | "91": "237", 834 | "92": "239", 835 | "93": "241", 836 | "94": "244", 837 | "95": "247", 838 | "96": "250", 839 | "97": "254", 840 | "98": "231", 841 | "99": "default", 842 | } 843 | 844 | assert color_string in line_colors 845 | 846 | return line_colors[color_string] 847 | 848 | 849 | # The functions color_dist_sq(), color_to_6cube(), and color_find_rgb 850 | # are python ports of the same named functions from the tmux 851 | # source, they are under the copyright of Nicholas Marriott, and Avi Halachmi 852 | # under the ISC license. 853 | # More info: https://github.com/tmux/tmux/blob/master/colour.c 854 | 855 | 856 | def color_dist_sq(R, G, B, r, g, b): 857 | # pylint: disable=invalid-name,too-many-arguments 858 | # type: (int, int, int, int, int, int) -> int 859 | return (R - r) * (R - r) + (G - g) * (G - g) + (B - b) * (B - b) 860 | 861 | 862 | def color_to_6cube(v): 863 | # pylint: disable=invalid-name 864 | # type: (int) -> int 865 | if v < 48: 866 | return 0 867 | if v < 114: 868 | return 1 869 | return (v - 35) // 40 870 | 871 | 872 | def color_find_rgb(r, g, b): 873 | # type: (int, int, int) -> int 874 | """Convert an RGB triplet to the xterm(1) 256 color palette. 875 | 876 | xterm provides a 6x6x6 color cube (16 - 231) and 24 greys (232 - 255). 877 | We map our RGB color to the closest in the cube, also work out the 878 | closest grey, and use the nearest of the two. 879 | 880 | Note that the xterm has much lower resolution for darker colors (they 881 | are not evenly spread out), so our 6 levels are not evenly spread: 0x0, 882 | 0x5f (95), 0x87 (135), 0xaf (175), 0xd7 (215) and 0xff (255). Greys are 883 | more evenly spread (8, 18, 28 ... 238). 884 | """ 885 | # pylint: disable=invalid-name 886 | q2c = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff] 887 | 888 | # Map RGB to 6x6x6 cube. 889 | qr = color_to_6cube(r) 890 | qg = color_to_6cube(g) 891 | qb = color_to_6cube(b) 892 | 893 | cr = q2c[qr] 894 | cg = q2c[qg] 895 | cb = q2c[qb] 896 | 897 | # If we have hit the color exactly, return early. 898 | if cr == r and cg == g and cb == b: 899 | return 16 + (36 * qr) + (6 * qg) + qb 900 | 901 | # Work out the closest grey (average of RGB). 902 | grey_avg = (r + g + b) // 3 903 | 904 | if grey_avg > 238: 905 | grey_idx = 23 906 | else: 907 | grey_idx = (grey_avg - 3) // 10 908 | 909 | grey = 8 + (10 * grey_idx) 910 | 911 | # Is grey or 6x6x6 color closest? 912 | d = color_dist_sq(cr, cg, cb, r, g, b) 913 | 914 | if color_dist_sq(grey, grey, grey, r, g, b) < d: 915 | idx = 232 + grey_idx 916 | else: 917 | idx = 16 + (36 * qr) + (6 * qg) + qb 918 | 919 | return idx 920 | 921 | 922 | def color_html_to_weechat(color): 923 | # type: (str) -> str 924 | # yapf: disable 925 | weechat_basic_colors = { 926 | (0, 0, 0): "black", # 0 927 | (128, 0, 0): "red", # 1 928 | (0, 128, 0): "green", # 2 929 | (128, 128, 0): "brown", # 3 930 | (0, 0, 128): "blue", # 4 931 | (128, 0, 128): "magenta", # 5 932 | (0, 128, 128): "cyan", # 6 933 | (192, 192, 192): "default", # 7 934 | (128, 128, 128): "gray", # 8 935 | (255, 0, 0): "lightred", # 9 936 | (0, 255, 0): "lightgreen", # 10 937 | (255, 255, 0): "yellow", # 11 938 | (0, 0, 255): "lightblue", # 12 939 | (255, 0, 255): "lightmagenta", # 13 940 | (0, 255, 255): "lightcyan", # 14 941 | (255, 255, 255): "white", # 15 942 | } 943 | # yapf: enable 944 | 945 | try: 946 | rgb_color = webcolors.html5_parse_legacy_color(color) 947 | except ValueError: 948 | return "" 949 | 950 | if rgb_color in weechat_basic_colors: 951 | return weechat_basic_colors[rgb_color] 952 | 953 | return str(color_find_rgb(*rgb_color)) 954 | 955 | 956 | def color_weechat_to_html(color): 957 | # type: (str) -> str 958 | # yapf: disable 959 | weechat_basic_colors = { 960 | "black": "0", 961 | "red": "1", 962 | "green": "2", 963 | "brown": "3", 964 | "blue": "4", 965 | "magenta": "5", 966 | "cyan": "6", 967 | "default": "7", 968 | "gray": "8", 969 | "lightred": "9", 970 | "lightgreen": "10", 971 | "yellow": "11", 972 | "lightblue": "12", 973 | "lightmagenta": "13", 974 | "lightcyan": "14", 975 | "white": "15", 976 | } 977 | hex_colors = { 978 | "0": "#000000", 979 | "1": "#800000", 980 | "2": "#008000", 981 | "3": "#808000", 982 | "4": "#000080", 983 | "5": "#800080", 984 | "6": "#008080", 985 | "7": "#c0c0c0", 986 | "8": "#808080", 987 | "9": "#ff0000", 988 | "10": "#00ff00", 989 | "11": "#ffff00", 990 | "12": "#0000ff", 991 | "13": "#ff00ff", 992 | "14": "#00ffff", 993 | "15": "#ffffff", 994 | "16": "#000000", 995 | "17": "#00005f", 996 | "18": "#000087", 997 | "19": "#0000af", 998 | "20": "#0000d7", 999 | "21": "#0000ff", 1000 | "22": "#005f00", 1001 | "23": "#005f5f", 1002 | "24": "#005f87", 1003 | "25": "#005faf", 1004 | "26": "#005fd7", 1005 | "27": "#005fff", 1006 | "28": "#008700", 1007 | "29": "#00875f", 1008 | "30": "#008787", 1009 | "31": "#0087af", 1010 | "32": "#0087d7", 1011 | "33": "#0087ff", 1012 | "34": "#00af00", 1013 | "35": "#00af5f", 1014 | "36": "#00af87", 1015 | "37": "#00afaf", 1016 | "38": "#00afd7", 1017 | "39": "#00afff", 1018 | "40": "#00d700", 1019 | "41": "#00d75f", 1020 | "42": "#00d787", 1021 | "43": "#00d7af", 1022 | "44": "#00d7d7", 1023 | "45": "#00d7ff", 1024 | "46": "#00ff00", 1025 | "47": "#00ff5f", 1026 | "48": "#00ff87", 1027 | "49": "#00ffaf", 1028 | "50": "#00ffd7", 1029 | "51": "#00ffff", 1030 | "52": "#5f0000", 1031 | "53": "#5f005f", 1032 | "54": "#5f0087", 1033 | "55": "#5f00af", 1034 | "56": "#5f00d7", 1035 | "57": "#5f00ff", 1036 | "58": "#5f5f00", 1037 | "59": "#5f5f5f", 1038 | "60": "#5f5f87", 1039 | "61": "#5f5faf", 1040 | "62": "#5f5fd7", 1041 | "63": "#5f5fff", 1042 | "64": "#5f8700", 1043 | "65": "#5f875f", 1044 | "66": "#5f8787", 1045 | "67": "#5f87af", 1046 | "68": "#5f87d7", 1047 | "69": "#5f87ff", 1048 | "70": "#5faf00", 1049 | "71": "#5faf5f", 1050 | "72": "#5faf87", 1051 | "73": "#5fafaf", 1052 | "74": "#5fafd7", 1053 | "75": "#5fafff", 1054 | "76": "#5fd700", 1055 | "77": "#5fd75f", 1056 | "78": "#5fd787", 1057 | "79": "#5fd7af", 1058 | "80": "#5fd7d7", 1059 | "81": "#5fd7ff", 1060 | "82": "#5fff00", 1061 | "83": "#5fff5f", 1062 | "84": "#5fff87", 1063 | "85": "#5fffaf", 1064 | "86": "#5fffd7", 1065 | "87": "#5fffff", 1066 | "88": "#870000", 1067 | "89": "#87005f", 1068 | "90": "#870087", 1069 | "91": "#8700af", 1070 | "92": "#8700d7", 1071 | "93": "#8700ff", 1072 | "94": "#875f00", 1073 | "95": "#875f5f", 1074 | "96": "#875f87", 1075 | "97": "#875faf", 1076 | "98": "#875fd7", 1077 | "99": "#875fff", 1078 | "100": "#878700", 1079 | "101": "#87875f", 1080 | "102": "#878787", 1081 | "103": "#8787af", 1082 | "104": "#8787d7", 1083 | "105": "#8787ff", 1084 | "106": "#87af00", 1085 | "107": "#87af5f", 1086 | "108": "#87af87", 1087 | "109": "#87afaf", 1088 | "110": "#87afd7", 1089 | "111": "#87afff", 1090 | "112": "#87d700", 1091 | "113": "#87d75f", 1092 | "114": "#87d787", 1093 | "115": "#87d7af", 1094 | "116": "#87d7d7", 1095 | "117": "#87d7ff", 1096 | "118": "#87ff00", 1097 | "119": "#87ff5f", 1098 | "120": "#87ff87", 1099 | "121": "#87ffaf", 1100 | "122": "#87ffd7", 1101 | "123": "#87ffff", 1102 | "124": "#af0000", 1103 | "125": "#af005f", 1104 | "126": "#af0087", 1105 | "127": "#af00af", 1106 | "128": "#af00d7", 1107 | "129": "#af00ff", 1108 | "130": "#af5f00", 1109 | "131": "#af5f5f", 1110 | "132": "#af5f87", 1111 | "133": "#af5faf", 1112 | "134": "#af5fd7", 1113 | "135": "#af5fff", 1114 | "136": "#af8700", 1115 | "137": "#af875f", 1116 | "138": "#af8787", 1117 | "139": "#af87af", 1118 | "140": "#af87d7", 1119 | "141": "#af87ff", 1120 | "142": "#afaf00", 1121 | "143": "#afaf5f", 1122 | "144": "#afaf87", 1123 | "145": "#afafaf", 1124 | "146": "#afafd7", 1125 | "147": "#afafff", 1126 | "148": "#afd700", 1127 | "149": "#afd75f", 1128 | "150": "#afd787", 1129 | "151": "#afd7af", 1130 | "152": "#afd7d7", 1131 | "153": "#afd7ff", 1132 | "154": "#afff00", 1133 | "155": "#afff5f", 1134 | "156": "#afff87", 1135 | "157": "#afffaf", 1136 | "158": "#afffd7", 1137 | "159": "#afffff", 1138 | "160": "#d70000", 1139 | "161": "#d7005f", 1140 | "162": "#d70087", 1141 | "163": "#d700af", 1142 | "164": "#d700d7", 1143 | "165": "#d700ff", 1144 | "166": "#d75f00", 1145 | "167": "#d75f5f", 1146 | "168": "#d75f87", 1147 | "169": "#d75faf", 1148 | "170": "#d75fd7", 1149 | "171": "#d75fff", 1150 | "172": "#d78700", 1151 | "173": "#d7875f", 1152 | "174": "#d78787", 1153 | "175": "#d787af", 1154 | "176": "#d787d7", 1155 | "177": "#d787ff", 1156 | "178": "#d7af00", 1157 | "179": "#d7af5f", 1158 | "180": "#d7af87", 1159 | "181": "#d7afaf", 1160 | "182": "#d7afd7", 1161 | "183": "#d7afff", 1162 | "184": "#d7d700", 1163 | "185": "#d7d75f", 1164 | "186": "#d7d787", 1165 | "187": "#d7d7af", 1166 | "188": "#d7d7d7", 1167 | "189": "#d7d7ff", 1168 | "190": "#d7ff00", 1169 | "191": "#d7ff5f", 1170 | "192": "#d7ff87", 1171 | "193": "#d7ffaf", 1172 | "194": "#d7ffd7", 1173 | "195": "#d7ffff", 1174 | "196": "#ff0000", 1175 | "197": "#ff005f", 1176 | "198": "#ff0087", 1177 | "199": "#ff00af", 1178 | "200": "#ff00d7", 1179 | "201": "#ff00ff", 1180 | "202": "#ff5f00", 1181 | "203": "#ff5f5f", 1182 | "204": "#ff5f87", 1183 | "205": "#ff5faf", 1184 | "206": "#ff5fd7", 1185 | "207": "#ff5fff", 1186 | "208": "#ff8700", 1187 | "209": "#ff875f", 1188 | "210": "#ff8787", 1189 | "211": "#ff87af", 1190 | "212": "#ff87d7", 1191 | "213": "#ff87ff", 1192 | "214": "#ffaf00", 1193 | "215": "#ffaf5f", 1194 | "216": "#ffaf87", 1195 | "217": "#ffafaf", 1196 | "218": "#ffafd7", 1197 | "219": "#ffafff", 1198 | "220": "#ffd700", 1199 | "221": "#ffd75f", 1200 | "222": "#ffd787", 1201 | "223": "#ffd7af", 1202 | "224": "#ffd7d7", 1203 | "225": "#ffd7ff", 1204 | "226": "#ffff00", 1205 | "227": "#ffff5f", 1206 | "228": "#ffff87", 1207 | "229": "#ffffaf", 1208 | "230": "#ffffd7", 1209 | "231": "#ffffff", 1210 | "232": "#080808", 1211 | "233": "#121212", 1212 | "234": "#1c1c1c", 1213 | "235": "#262626", 1214 | "236": "#303030", 1215 | "237": "#3a3a3a", 1216 | "238": "#444444", 1217 | "239": "#4e4e4e", 1218 | "240": "#585858", 1219 | "241": "#626262", 1220 | "242": "#6c6c6c", 1221 | "243": "#767676", 1222 | "244": "#808080", 1223 | "245": "#8a8a8a", 1224 | "246": "#949494", 1225 | "247": "#9e9e9e", 1226 | "248": "#a8a8a8", 1227 | "249": "#b2b2b2", 1228 | "250": "#bcbcbc", 1229 | "251": "#c6c6c6", 1230 | "252": "#d0d0d0", 1231 | "253": "#dadada", 1232 | "254": "#e4e4e4", 1233 | "255": "#eeeeee" 1234 | } 1235 | 1236 | # yapf: enable 1237 | if color in weechat_basic_colors: 1238 | return hex_colors[weechat_basic_colors[color]] 1239 | return hex_colors[color] 1240 | 1241 | 1242 | class WeechatFormatter(Formatter): 1243 | def __init__(self, **options): 1244 | Formatter.__init__(self, **options) 1245 | self.styles = {} 1246 | 1247 | for token, style in self.style: 1248 | start = end = "" 1249 | if style["color"]: 1250 | start += "{}".format( 1251 | W.color(color_html_to_weechat(str(style["color"]))) 1252 | ) 1253 | end = "{}".format(W.color("resetcolor")) + end 1254 | if style["bold"]: 1255 | start += W.color("bold") 1256 | end = W.color("-bold") + end 1257 | if style["italic"]: 1258 | start += W.color("italic") 1259 | end = W.color("-italic") + end 1260 | if style["underline"]: 1261 | start += W.color("underline") 1262 | end = W.color("-underline") + end 1263 | self.styles[token] = (start, end) 1264 | 1265 | def format(self, tokensource, outfile): 1266 | lastval = "" 1267 | lasttype = None 1268 | 1269 | for ttype, value in tokensource: 1270 | while ttype not in self.styles: 1271 | ttype = ttype.parent 1272 | 1273 | if ttype == lasttype: 1274 | lastval += value 1275 | else: 1276 | if lastval: 1277 | stylebegin, styleend = self.styles[lasttype] 1278 | outfile.write(stylebegin + lastval + styleend) 1279 | # set lastval/lasttype to current values 1280 | lastval = value 1281 | lasttype = ttype 1282 | 1283 | if lastval: 1284 | stylebegin, styleend = self.styles[lasttype] 1285 | outfile.write(stylebegin + lastval + styleend) 1286 | -------------------------------------------------------------------------------- /matrix/completion.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright © 2018, 2019 Damir Jelić 4 | # 5 | # Permission to use, copy, modify, and/or distribute this software for 6 | # any purpose with or without fee is hereby granted, provided that the 7 | # above copyright notice and this permission notice appear in all copies. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 12 | # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER 13 | # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF 14 | # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 15 | # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | 17 | from __future__ import unicode_literals 18 | 19 | from typing import List, Optional 20 | from matrix.globals import SERVERS, W, SCRIPT_NAME 21 | from matrix.utf import utf8_decode 22 | from matrix.utils import tags_from_line_data 23 | from nio import LocalProtocolError 24 | 25 | 26 | def add_servers_to_completion(completion): 27 | for server_name in SERVERS: 28 | W.hook_completion_list_add( 29 | completion, server_name, 0, W.WEECHAT_LIST_POS_SORT 30 | ) 31 | 32 | 33 | @utf8_decode 34 | def matrix_server_command_completion_cb( 35 | data, completion_item, buffer, completion 36 | ): 37 | buffer_input = W.buffer_get_string(buffer, "input").split() 38 | 39 | args = buffer_input[1:] 40 | commands = ["add", "delete", "list", "listfull"] 41 | 42 | def complete_commands(): 43 | for command in commands: 44 | W.hook_completion_list_add( 45 | completion, command, 0, W.WEECHAT_LIST_POS_SORT 46 | ) 47 | 48 | if len(args) == 1: 49 | complete_commands() 50 | 51 | elif len(args) == 2: 52 | if args[1] not in commands: 53 | complete_commands() 54 | else: 55 | if args[1] == "delete" or args[1] == "listfull": 56 | add_servers_to_completion(completion) 57 | 58 | elif len(args) == 3: 59 | if args[1] == "delete" or args[1] == "listfull": 60 | if args[2] not in SERVERS: 61 | add_servers_to_completion(completion) 62 | 63 | return W.WEECHAT_RC_OK 64 | 65 | 66 | @utf8_decode 67 | def matrix_server_completion_cb(data, completion_item, buffer, completion): 68 | add_servers_to_completion(completion) 69 | return W.WEECHAT_RC_OK 70 | 71 | 72 | @utf8_decode 73 | def matrix_command_completion_cb(data, completion_item, buffer, completion): 74 | for command in [ 75 | "connect", 76 | "disconnect", 77 | "reconnect", 78 | "server", 79 | "help", 80 | "debug", 81 | ]: 82 | W.hook_completion_list_add( 83 | completion, command, 0, W.WEECHAT_LIST_POS_SORT 84 | ) 85 | return W.WEECHAT_RC_OK 86 | 87 | 88 | @utf8_decode 89 | def matrix_debug_completion_cb(data, completion_item, buffer, completion): 90 | for debug_type in ["messaging", "network", "timing"]: 91 | W.hook_completion_list_add( 92 | completion, debug_type, 0, W.WEECHAT_LIST_POS_SORT 93 | ) 94 | return W.WEECHAT_RC_OK 95 | 96 | 97 | # TODO this should be configurable 98 | REDACTION_COMP_LEN = 50 99 | 100 | 101 | @utf8_decode 102 | def matrix_message_completion_cb(data, completion_item, buffer, completion): 103 | max_events = 500 104 | 105 | def redacted_or_not_message(tags): 106 | # type: (List[str]) -> bool 107 | if SCRIPT_NAME + "_redacted" in tags: 108 | return True 109 | if SCRIPT_NAME + "_message" not in tags: 110 | return True 111 | 112 | return False 113 | 114 | def event_id_from_tags(tags): 115 | # type: (List[str]) -> Optional[str] 116 | for tag in tags: 117 | if tag.startswith("matrix_id"): 118 | event_id = tag[10:] 119 | return event_id 120 | 121 | return None 122 | 123 | for server in SERVERS.values(): 124 | if buffer in server.buffers.values(): 125 | room_buffer = server.find_room_from_ptr(buffer) 126 | lines = room_buffer.weechat_buffer.lines 127 | 128 | added = 0 129 | 130 | for line in lines: 131 | tags = line.tags 132 | if redacted_or_not_message(tags): 133 | continue 134 | 135 | event_id = event_id_from_tags(tags) 136 | 137 | if not event_id: 138 | continue 139 | 140 | # Make sure we'll be able to reliably detect the end of the 141 | # quoted snippet 142 | message_fmt = line.message.replace("\\", "\\\\") \ 143 | .replace('"', '\\"') 144 | 145 | if len(message_fmt) > REDACTION_COMP_LEN + 2: 146 | message_fmt = message_fmt[:REDACTION_COMP_LEN] + ".." 147 | 148 | item = ('{event_id}|"{message}"').format( 149 | event_id=event_id, message=message_fmt 150 | ) 151 | 152 | W.hook_completion_list_add( 153 | completion, item, 0, W.WEECHAT_LIST_POS_END 154 | ) 155 | added += 1 156 | 157 | if added >= max_events: 158 | break 159 | 160 | return W.WEECHAT_RC_OK 161 | 162 | return W.WEECHAT_RC_OK 163 | 164 | 165 | def server_from_buffer(buffer): 166 | for server in SERVERS.values(): 167 | if buffer in server.buffers.values(): 168 | return server 169 | if buffer == server.server_buffer: 170 | return server 171 | return None 172 | 173 | 174 | @utf8_decode 175 | def matrix_olm_user_completion_cb(data, completion_item, buffer, completion): 176 | server = server_from_buffer(buffer) 177 | 178 | if not server: 179 | return W.WEECHAT_RC_OK 180 | 181 | try: 182 | device_store = server.client.device_store 183 | except LocalProtocolError: 184 | return W.WEECHAT_RC_OK 185 | 186 | for user in device_store.users: 187 | W.hook_completion_list_add( 188 | completion, user, 0, W.WEECHAT_LIST_POS_SORT 189 | ) 190 | 191 | return W.WEECHAT_RC_OK 192 | 193 | 194 | @utf8_decode 195 | def matrix_olm_device_completion_cb(data, completion_item, buffer, completion): 196 | server = server_from_buffer(buffer) 197 | 198 | if not server: 199 | return W.WEECHAT_RC_OK 200 | 201 | try: 202 | device_store = server.client.device_store 203 | except LocalProtocolError: 204 | return W.WEECHAT_RC_OK 205 | 206 | args = W.hook_completion_get_string(completion, "args") 207 | 208 | fields = args.split() 209 | 210 | if len(fields) < 2: 211 | return W.WEECHAT_RC_OK 212 | 213 | user = fields[-1] 214 | 215 | if user not in device_store.users: 216 | return W.WEECHAT_RC_OK 217 | 218 | for device in device_store.active_user_devices(user): 219 | W.hook_completion_list_add( 220 | completion, device.id, 0, W.WEECHAT_LIST_POS_SORT 221 | ) 222 | 223 | return W.WEECHAT_RC_OK 224 | 225 | 226 | @utf8_decode 227 | def matrix_own_devices_completion_cb( 228 | data, 229 | completion_item, 230 | buffer, 231 | completion 232 | ): 233 | server = server_from_buffer(buffer) 234 | 235 | if not server: 236 | return W.WEECHAT_RC_OK 237 | 238 | olm = server.client.olm 239 | 240 | if not olm: 241 | return W.WEECHAT_RC_OK 242 | 243 | W.hook_completion_list_add( 244 | completion, olm.device_id, 0, W.WEECHAT_LIST_POS_SORT 245 | ) 246 | 247 | user = olm.user_id 248 | 249 | if user not in olm.device_store.users: 250 | return W.WEECHAT_RC_OK 251 | 252 | for device in olm.device_store.active_user_devices(user): 253 | W.hook_completion_list_add( 254 | completion, device.id, 0, W.WEECHAT_LIST_POS_SORT 255 | ) 256 | 257 | return W.WEECHAT_RC_OK 258 | 259 | 260 | @utf8_decode 261 | def matrix_user_completion_cb(data, completion_item, buffer, completion): 262 | def add_user(completion, user): 263 | W.hook_completion_list_add( 264 | completion, user, 0, W.WEECHAT_LIST_POS_SORT 265 | ) 266 | 267 | for server in SERVERS.values(): 268 | if buffer == server.server_buffer: 269 | return W.WEECHAT_RC_OK 270 | 271 | room_buffer = server.find_room_from_ptr(buffer) 272 | 273 | if not room_buffer: 274 | continue 275 | 276 | users = room_buffer.room.users 277 | 278 | users = [user[1:] for user in users] 279 | 280 | for user in users: 281 | add_user(completion, user) 282 | 283 | return W.WEECHAT_RC_OK 284 | 285 | 286 | @utf8_decode 287 | def matrix_room_completion_cb(data, completion_item, buffer, completion): 288 | """Completion callback for matrix room names.""" 289 | for server in SERVERS.values(): 290 | for room_buffer in server.room_buffers.values(): 291 | name = room_buffer.weechat_buffer.short_name 292 | 293 | W.hook_completion_list_add( 294 | completion, name, 0, W.WEECHAT_LIST_POS_SORT 295 | ) 296 | 297 | return W.WEECHAT_RC_OK 298 | 299 | 300 | def init_completion(): 301 | W.hook_completion( 302 | "matrix_server_commands", 303 | "Matrix server completion", 304 | "matrix_server_command_completion_cb", 305 | "", 306 | ) 307 | 308 | W.hook_completion( 309 | "matrix_servers", 310 | "Matrix server completion", 311 | "matrix_server_completion_cb", 312 | "", 313 | ) 314 | 315 | W.hook_completion( 316 | "matrix_commands", 317 | "Matrix command completion", 318 | "matrix_command_completion_cb", 319 | "", 320 | ) 321 | 322 | W.hook_completion( 323 | "matrix_messages", 324 | "Matrix message completion", 325 | "matrix_message_completion_cb", 326 | "", 327 | ) 328 | 329 | W.hook_completion( 330 | "matrix_debug_types", 331 | "Matrix debugging type completion", 332 | "matrix_debug_completion_cb", 333 | "", 334 | ) 335 | 336 | W.hook_completion( 337 | "olm_user_ids", 338 | "Matrix olm user id completion", 339 | "matrix_olm_user_completion_cb", 340 | "", 341 | ) 342 | 343 | W.hook_completion( 344 | "olm_devices", 345 | "Matrix olm device id completion", 346 | "matrix_olm_device_completion_cb", 347 | "", 348 | ) 349 | 350 | W.hook_completion( 351 | "matrix_users", 352 | "Matrix user id completion", 353 | "matrix_user_completion_cb", 354 | "", 355 | ) 356 | 357 | W.hook_completion( 358 | "matrix_own_devices", 359 | "Matrix own devices completion", 360 | "matrix_own_devices_completion_cb", 361 | "", 362 | ) 363 | 364 | W.hook_completion( 365 | "matrix_rooms", 366 | "Matrix room name completion", 367 | "matrix_room_completion_cb", 368 | "", 369 | ) 370 | -------------------------------------------------------------------------------- /matrix/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright © 2018, 2019 Damir Jelić 4 | # Copyright © 2018, 2019 Denis Kasak 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for 7 | # any purpose with or without fee is hereby granted, provided that the 8 | # above copyright notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 13 | # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER 14 | # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF 15 | # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 16 | # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | 18 | """weechat-matrix Configuration module. 19 | 20 | This module contains abstractions on top of weechats configuration files and 21 | the main script configuration class. 22 | 23 | To add configuration options refer to MatrixConfig. 24 | Server specific configuration options are handled in server.py 25 | """ 26 | 27 | import logging 28 | from builtins import super 29 | from collections import namedtuple 30 | from enum import IntEnum, Enum, unique 31 | 32 | import nio 33 | from matrix.globals import SCRIPT_NAME, SERVERS, W 34 | from matrix.utf import utf8_decode 35 | 36 | from . import globals as G 37 | 38 | 39 | @unique 40 | class RedactType(Enum): 41 | STRIKETHROUGH = 0 42 | NOTICE = 1 43 | DELETE = 2 44 | 45 | 46 | @unique 47 | class ServerBufferType(Enum): 48 | MERGE_CORE = 0 49 | MERGE = 1 50 | INDEPENDENT = 2 51 | 52 | 53 | @unique 54 | class NewChannelPosition(IntEnum): 55 | NONE = 0 56 | NEXT = 1 57 | NEAR_SERVER = 2 58 | 59 | nio_logger = logging.getLogger("nio") 60 | nio_logger.setLevel(logging.ERROR) 61 | 62 | class Option( 63 | namedtuple( 64 | "Option", 65 | [ 66 | "name", 67 | "type", 68 | "string_values", 69 | "min", 70 | "max", 71 | "value", 72 | "description", 73 | "cast_func", 74 | "change_callback", 75 | ], 76 | ) 77 | ): 78 | """A class representing a new configuration option. 79 | 80 | An option object is consumed by the ConfigSection class adding 81 | configuration options to weechat. 82 | """ 83 | 84 | __slots__ = () 85 | 86 | def __new__( 87 | cls, 88 | name, 89 | type, 90 | string_values, 91 | min, 92 | max, 93 | value, 94 | description, 95 | cast=None, 96 | change_callback=None, 97 | ): 98 | """ 99 | Parameters: 100 | name (str): Name of the configuration option 101 | type (str): Type of the configuration option, can be one of the 102 | supported weechat types: string, boolean, integer, color 103 | string_values: (str): A list of string values that the option can 104 | accept seprated by | 105 | min (int): Minimal value of the option, only used if the type of 106 | the option is integer 107 | max (int): Maximal value of the option, only used if the type of 108 | the option is integer 109 | description (str): Description of the configuration option 110 | cast (callable): A callable function taking a single value and 111 | returning a modified value. Useful to turn the configuration 112 | option into an enum while reading it. 113 | change_callback(callable): A function that will be called 114 | by weechat every time the configuration option is changed. 115 | """ 116 | 117 | return super().__new__( 118 | cls, 119 | name, 120 | type, 121 | string_values, 122 | min, 123 | max, 124 | value, 125 | description, 126 | cast, 127 | change_callback, 128 | ) 129 | 130 | 131 | @utf8_decode 132 | def matrix_config_reload_cb(data, config_file): 133 | return W.WEECHAT_RC_OK 134 | 135 | 136 | def change_log_level(category, level): 137 | """Change the log level of the underlying nio lib 138 | 139 | Called every time the user changes the log level or log category 140 | configuration option.""" 141 | 142 | if category == "encryption": 143 | category = "crypto" 144 | 145 | if category == "all": 146 | nio_logger.setLevel(level) 147 | else: 148 | nio_logger.getChild(category).setLevel(level) 149 | 150 | 151 | @utf8_decode 152 | def config_server_buffer_cb(data, option): 153 | """Callback for the look.server_buffer option. 154 | Is called when the option is changed and merges/splits the server 155 | buffer""" 156 | 157 | for server in SERVERS.values(): 158 | server.buffer_merge() 159 | return 1 160 | 161 | 162 | @utf8_decode 163 | def config_log_level_cb(data, option): 164 | """Callback for the network.debug_level option.""" 165 | change_log_level( 166 | G.CONFIG.network.debug_category, G.CONFIG.network.debug_level 167 | ) 168 | return 1 169 | 170 | 171 | @utf8_decode 172 | def config_log_category_cb(data, option): 173 | """Callback for the network.debug_category option.""" 174 | change_log_level(G.CONFIG.debug_category, logging.ERROR) 175 | G.CONFIG.debug_category = G.CONFIG.network.debug_category 176 | change_log_level( 177 | G.CONFIG.network.debug_category, G.CONFIG.network.debug_level 178 | ) 179 | return 1 180 | 181 | 182 | @utf8_decode 183 | def config_pgup_cb(data, option): 184 | """Callback for the network.fetch_backlog_on_pgup option. 185 | Enables or disables the hook that is run when /window page_up is called""" 186 | if G.CONFIG.network.fetch_backlog_on_pgup: 187 | if not G.CONFIG.page_up_hook: 188 | G.CONFIG.page_up_hook = W.hook_command_run( 189 | "/window page_up", "matrix_command_pgup_cb", "" 190 | ) 191 | else: 192 | if G.CONFIG.page_up_hook: 193 | W.unhook(G.CONFIG.page_up_hook) 194 | G.CONFIG.page_up_hook = None 195 | 196 | return 1 197 | 198 | 199 | def level_to_logging(value): 200 | if value == 0: 201 | return logging.ERROR 202 | if value == 1: 203 | return logging.WARNING 204 | if value == 2: 205 | return logging.INFO 206 | if value == 3: 207 | return logging.DEBUG 208 | 209 | return logging.ERROR 210 | 211 | 212 | def logging_category(value): 213 | if value == 0: 214 | return "all" 215 | if value == 1: 216 | return "http" 217 | if value == 2: 218 | return "client" 219 | if value == 3: 220 | return "events" 221 | if value == 4: 222 | return "responses" 223 | if value == 5: 224 | return "encryption" 225 | 226 | return "all" 227 | 228 | 229 | def parse_nick_prefix_colors(value): 230 | """Parses the nick prefix color setting string 231 | ("admin=COLOR1;mod=COLOR2;power=COLOR3") into a prefix -> color dict.""" 232 | 233 | def key_to_prefix(key): 234 | if key == "admin": 235 | return "&" 236 | elif key == "mod": 237 | return "@" 238 | elif key == "power": 239 | return "+" 240 | else: 241 | return "" 242 | 243 | prefix_colors = { 244 | "&": "lightgreen", 245 | "@": "lightgreen", 246 | "+": "yellow", 247 | } 248 | 249 | for setting in value.split(";"): 250 | # skip malformed settings 251 | if "=" not in setting: 252 | continue 253 | 254 | key, color = setting.split("=") 255 | prefix = key_to_prefix(key) 256 | 257 | if prefix: 258 | prefix_colors[prefix] = color 259 | 260 | return prefix_colors 261 | 262 | 263 | def eval_cast(string): 264 | """A function that passes a string to weechat which evaluates it using its 265 | expression evaluation syntax. 266 | Can only be used with strings, useful for passwords or options that contain 267 | a formatted string to e.g. add colors. 268 | More info here: 269 | https://weechat.org/files/doc/stable/weechat_plugin_api.en.html#_string_eval_expression""" 270 | 271 | return W.string_eval_expression(string, {}, {}, {}) 272 | 273 | 274 | class WeechatConfig(object): 275 | """A class representing a weechat configuration file 276 | Wraps weechats configuration creation functionality""" 277 | 278 | def __init__(self, sections): 279 | """Create a new weechat configuration file, expects the global 280 | SCRIPT_NAME to be defined and a reload callback 281 | 282 | Parameters: 283 | sections (List[Tuple[str, List[Option]]]): List of config sections 284 | that will be created for the configuration file. 285 | """ 286 | self._ptr = W.config_new( 287 | SCRIPT_NAME, SCRIPT_NAME + "_config_reload_cb", "" 288 | ) 289 | 290 | for section in sections: 291 | name, options = section 292 | section_class = ConfigSection.build(name, options) 293 | setattr(self, name, section_class(name, self._ptr, options)) 294 | 295 | def free(self): 296 | """Free all the config sections and their options as well as the 297 | configuration file. Should be called when the script is unloaded.""" 298 | for section in [ 299 | getattr(self, a) 300 | for a in dir(self) 301 | if isinstance(getattr(self, a), ConfigSection) 302 | ]: 303 | section.free() 304 | 305 | W.config_free(self._ptr) 306 | 307 | def read(self): 308 | """Read the config file""" 309 | return_code = W.config_read(self._ptr) 310 | if return_code == W.WEECHAT_CONFIG_READ_OK: 311 | return True 312 | if return_code == W.WEECHAT_CONFIG_READ_MEMORY_ERROR: 313 | return False 314 | if return_code == W.WEECHAT_CONFIG_READ_FILE_NOT_FOUND: 315 | return True 316 | return False 317 | 318 | 319 | class ConfigSection(object): 320 | """A class representing a weechat config section. 321 | Should not be used on its own, the WeechatConfig class uses this to build 322 | config sections.""" 323 | @classmethod 324 | def build(cls, name, options): 325 | def constructor(self, name, config_ptr, options): 326 | self._ptr = W.config_new_section( 327 | config_ptr, name, 0, 0, "", "", "", "", "", "", "", "", "", "" 328 | ) 329 | self._config_ptr = config_ptr 330 | self._option_ptrs = {} 331 | 332 | for option in options: 333 | self._add_option(option) 334 | 335 | attributes = { 336 | option.name: cls.option_property( 337 | option.name, option.type, cast_func=option.cast_func 338 | ) 339 | for option in options 340 | } 341 | attributes["__init__"] = constructor 342 | 343 | section_class = type(name.title() + "Section", (cls,), attributes) 344 | return section_class 345 | 346 | def free(self): 347 | W.config_section_free_options(self._ptr) 348 | W.config_section_free(self._ptr) 349 | 350 | def _add_option(self, option): 351 | cb = option.change_callback.__name__ if option.change_callback else "" 352 | option_ptr = W.config_new_option( 353 | self._config_ptr, 354 | self._ptr, 355 | option.name, 356 | option.type, 357 | option.description, 358 | option.string_values, 359 | option.min, 360 | option.max, 361 | option.value, 362 | option.value, 363 | 0, 364 | "", 365 | "", 366 | cb, 367 | "", 368 | "", 369 | "", 370 | ) 371 | 372 | self._option_ptrs[option.name] = option_ptr 373 | 374 | @staticmethod 375 | def option_property(name, option_type, evaluate=False, cast_func=None): 376 | """Create a property for this class that makes the reading of config 377 | option values pythonic. The option will be available as a property with 378 | the name of the option. 379 | If a cast function was defined for the option the property will pass 380 | the option value to the cast function and return its result.""" 381 | 382 | def bool_getter(self): 383 | return bool(W.config_boolean(self._option_ptrs[name])) 384 | 385 | def str_getter(self): 386 | if cast_func: 387 | return cast_func(W.config_string(self._option_ptrs[name])) 388 | return W.config_string(self._option_ptrs[name]) 389 | 390 | def str_evaluate_getter(self): 391 | return W.string_eval_expression( 392 | W.config_string(self._option_ptrs[name]), {}, {}, {} 393 | ) 394 | 395 | def int_getter(self): 396 | if cast_func: 397 | return cast_func(W.config_integer(self._option_ptrs[name])) 398 | return W.config_integer(self._option_ptrs[name]) 399 | 400 | if option_type in ("string", "color"): 401 | if evaluate: 402 | return property(str_evaluate_getter) 403 | return property(str_getter) 404 | if option_type == "boolean": 405 | return property(bool_getter) 406 | if option_type == "integer": 407 | return property(int_getter) 408 | 409 | 410 | class MatrixConfig(WeechatConfig): 411 | """Main matrix configuration file. 412 | This class defines all the global matrix configuration options. 413 | New global options should be added to the constructor of this class under 414 | the appropriate section. 415 | 416 | There are three main sections defined: 417 | Look: This section is for options that change the way matrix messages 418 | are shown or the way the buffers are shown. 419 | Color: This section should mainly be for color options, options that 420 | change color schemes or themes should go to the look section. 421 | Network: This section is for options that change the way the script 422 | behaves, e.g. the way it communicates with the server, it handles 423 | responses or any other behavioural change that doesn't fit in the 424 | previous sections. 425 | 426 | There is a special section called server defined which contains per server 427 | configuration options. Server options aren't defined here, they need to be 428 | added in server.py 429 | """ 430 | 431 | def __init__(self): 432 | self.debug_buffer = "" 433 | self.upload_buffer = "" 434 | self.debug_category = "all" 435 | self.page_up_hook = None 436 | self.human_buffer_names = None 437 | 438 | look_options = [ 439 | Option( 440 | "redactions", 441 | "integer", 442 | "strikethrough|notice|delete", 443 | 0, 444 | 0, 445 | "strikethrough", 446 | ( 447 | "Only notice redactions, strike through or delete " 448 | "redacted messages" 449 | ), 450 | RedactType, 451 | ), 452 | Option( 453 | "server_buffer", 454 | "integer", 455 | "merge_with_core|merge_without_core|independent", 456 | 0, 457 | 0, 458 | "merge_with_core", 459 | "Merge server buffers", 460 | ServerBufferType, 461 | config_server_buffer_cb, 462 | ), 463 | Option( 464 | "new_channel_position", 465 | "integer", 466 | "none|next|near_server", 467 | min(NewChannelPosition), 468 | max(NewChannelPosition), 469 | "none", 470 | "force position of new channel in list of buffers " 471 | "(none = default position (should be last buffer), " 472 | "next = current buffer + 1, near_server = after last " 473 | "channel/pv of server)", 474 | NewChannelPosition, 475 | ), 476 | Option( 477 | "max_typing_notice_item_length", 478 | "integer", 479 | "", 480 | 10, 481 | 1000, 482 | "50", 483 | ("Limit the length of the typing notice bar item."), 484 | ), 485 | Option( 486 | "bar_item_typing_notice_prefix", 487 | "string", 488 | "", 489 | 0, 490 | 0, 491 | "Typing: ", 492 | ("Prefix for the typing notice bar item."), 493 | ), 494 | Option( 495 | "encryption_warning_sign", 496 | "string", 497 | "", 498 | 0, 499 | 0, 500 | "⚠️ ", 501 | ("A sign that is used to signal trust issues in encrypted " 502 | "rooms (note: content is evaluated, see /help eval)"), 503 | eval_cast, 504 | ), 505 | Option( 506 | "busy_sign", 507 | "string", 508 | "", 509 | 0, 510 | 0, 511 | "⏳", 512 | ("A sign that is used to signal that the client is busy e.g. " 513 | "when the room backlog is fetching" 514 | " (note: content is evaluated, see /help eval)"), 515 | eval_cast, 516 | ), 517 | Option( 518 | "encrypted_room_sign", 519 | "string", 520 | "", 521 | 0, 522 | 0, 523 | "🔐", 524 | ("A sign that is used to show that the current room is " 525 | "encrypted " 526 | "(note: content is evaluated, see /help eval)"), 527 | eval_cast, 528 | ), 529 | Option( 530 | "disconnect_sign", 531 | "string", 532 | "", 533 | 0, 534 | 0, 535 | "❌", 536 | ("A sign that is used to show that the server is disconnected " 537 | "(note: content is evaluated, see /help eval)"), 538 | eval_cast, 539 | ), 540 | Option( 541 | "pygments_style", 542 | "string", 543 | "", 544 | 0, 545 | 0, 546 | "native", 547 | "Pygments style to use for highlighting source code blocks", 548 | ), 549 | Option( 550 | "code_blocks", 551 | "boolean", 552 | "", 553 | 0, 554 | 0, 555 | "on", 556 | ("Display preformatted code blocks as rectangular areas by " 557 | "padding them with whitespace up to the length of the longest" 558 | " line (with optional margin)"), 559 | ), 560 | Option( 561 | "code_block_margin", 562 | "integer", 563 | "", 564 | 0, 565 | 100, 566 | "2", 567 | ("Number of spaces to add as a margin around around a code " 568 | "block"), 569 | ), 570 | Option( 571 | "quote_wrap", 572 | "integer", 573 | "", 574 | -1, 575 | 1000, 576 | "67", 577 | ("After how many characters to soft-wrap lines in a quote " 578 | "block (reply message). Set to -1 to disable soft-wrapping."), 579 | ), 580 | Option( 581 | "human_buffer_names", 582 | "boolean", 583 | "", 584 | 0, 585 | 0, 586 | "off", 587 | ("If turned on the buffer name will consist of the server " 588 | "name and the room name instead of the Matrix room ID. Note, " 589 | "this requires a change to the logger.file.mask setting " 590 | "since conflicts can happen otherwise " 591 | "(requires a script reload)."), 592 | ), 593 | Option( 594 | "markdown_input", 595 | "boolean", 596 | "", 597 | 0, 598 | 0, 599 | "on", 600 | ("If turned on, markdown usage in messages will be converted " 601 | "to actual markup (**bold**, *italic*, _italic_, `code`)."), 602 | ), 603 | ] 604 | 605 | network_options = [ 606 | Option( 607 | "max_initial_sync_events", 608 | "integer", 609 | "", 610 | 1, 611 | 10000, 612 | "30", 613 | ("How many events to fetch during the initial sync"), 614 | ), 615 | Option( 616 | "max_backlog_sync_events", 617 | "integer", 618 | "", 619 | 1, 620 | 100, 621 | "10", 622 | ("How many events to fetch during backlog fetching"), 623 | ), 624 | Option( 625 | "fetch_backlog_on_pgup", 626 | "boolean", 627 | "", 628 | 0, 629 | 0, 630 | "on", 631 | ("Fetch messages in the backlog on a window page up event"), 632 | None, 633 | config_pgup_cb, 634 | ), 635 | Option( 636 | "debug_level", 637 | "integer", 638 | "error|warn|info|debug", 639 | 0, 640 | 0, 641 | "error", 642 | "Enable network protocol debugging.", 643 | level_to_logging, 644 | config_log_level_cb, 645 | ), 646 | Option( 647 | "debug_category", 648 | "integer", 649 | "all|http|client|events|responses|encryption", 650 | 0, 651 | 0, 652 | "all", 653 | "Debugging category", 654 | logging_category, 655 | config_log_category_cb, 656 | ), 657 | Option( 658 | "debug_buffer", 659 | "boolean", 660 | "", 661 | 0, 662 | 0, 663 | "off", 664 | ("Use a separate buffer for debug logs."), 665 | ), 666 | Option( 667 | "lazy_load_room_users", 668 | "boolean", 669 | "", 670 | 0, 671 | 0, 672 | "off", 673 | ("If on, room users won't be loaded in the background " 674 | "proactively, they will be loaded when the user switches to " 675 | "the room buffer. This only affects non-encrypted rooms."), 676 | ), 677 | Option( 678 | "max_nicklist_users", 679 | "integer", 680 | "", 681 | 100, 682 | 20000, 683 | "5000", 684 | ("Limit the number of users that are added to the nicklist. " 685 | "Active users and users with a higher power level are always." 686 | " Inactive users will be removed from the nicklist after a " 687 | "day of inactivity."), 688 | ), 689 | Option( 690 | "lag_reconnect", 691 | "integer", 692 | "", 693 | 5, 694 | 604800, 695 | "90", 696 | ("Reconnect to the server if the lag is greater than this " 697 | "value (in seconds)"), 698 | ), 699 | Option( 700 | "autoreconnect_delay_growing", 701 | "integer", 702 | "", 703 | 1, 704 | 100, 705 | "2", 706 | ("growing factor for autoreconnect delay to server " 707 | "(1 = always same delay, 2 = delay*2 for each retry, etc.)"), 708 | ), 709 | Option( 710 | "autoreconnect_delay_max", 711 | "integer", 712 | "", 713 | 0, 714 | 604800, 715 | "600", 716 | ("maximum autoreconnect delay to server " 717 | "(in seconds, 0 = no maximum)"), 718 | ), 719 | Option( 720 | "print_unconfirmed_messages", 721 | "boolean", 722 | "", 723 | 0, 724 | 0, 725 | "on", 726 | ("If off, messages are only printed after the server confirms " 727 | "their receival. If on, messages are immediately printed but " 728 | "colored differently until receival is confirmed."), 729 | ), 730 | Option( 731 | "lag_min_show", 732 | "integer", 733 | "", 734 | 1, 735 | 604800, 736 | "500", 737 | ("minimum lag to show (in milliseconds)"), 738 | ), 739 | Option( 740 | "typing_notice_conditions", 741 | "string", 742 | "", 743 | 0, 744 | 0, 745 | "${typing_enabled}", 746 | ("conditions to send typing notifications (note: content is " 747 | "evaluated, see /help eval); besides the buffer and window " 748 | "variables the typing_enabled variable is also expanded; " 749 | "the typing_enabled variable can be manipulated with the " 750 | "/room command, see /help room"), 751 | ), 752 | Option( 753 | "read_markers_conditions", 754 | "string", 755 | "", 756 | 0, 757 | 0, 758 | "${markers_enabled}", 759 | ("conditions to send read markers (note: content is " 760 | "evaluated, see /help eval); besides the buffer and window " 761 | "variables the markers_enabled variable is also expanded; " 762 | "the markers_enabled variable can be manipulated with the " 763 | "/room command, see /help room"), 764 | ), 765 | Option( 766 | "resending_ignores_devices", 767 | "boolean", 768 | "", 769 | 0, 770 | 0, 771 | "on", 772 | ("If on resending the same message to a room that contains " 773 | "unverified devices will mark the devices as ignored and " 774 | "continue sending the message. If off resending the message " 775 | "will again fail and devices need to be marked as verified " 776 | "one by one or the /send-anyways command needs to be used to " 777 | "ignore them."), 778 | ), 779 | ] 780 | 781 | color_options = [ 782 | Option( 783 | "quote_fg", 784 | "color", 785 | "", 786 | 0, 787 | 0, 788 | "lightgreen", 789 | "Foreground color for matrix style blockquotes", 790 | ), 791 | Option( 792 | "quote_bg", 793 | "color", 794 | "", 795 | 0, 796 | 0, 797 | "default", 798 | "Background counterpart of quote_fg", 799 | ), 800 | Option( 801 | "error_message_fg", 802 | "color", 803 | "", 804 | 0, 805 | 0, 806 | "darkgray", 807 | ("Foreground color for error messages that appear inside a " 808 | "room buffer (e.g. when a message errors out when sending or " 809 | "when a message is redacted)"), 810 | ), 811 | Option( 812 | "error_message_bg", 813 | "color", 814 | "", 815 | 0, 816 | 0, 817 | "default", 818 | "Background counterpart of error_message_fg.", 819 | ), 820 | Option( 821 | "unconfirmed_message_fg", 822 | "color", 823 | "", 824 | 0, 825 | 0, 826 | "darkgray", 827 | ("Foreground color for messages that are printed out but the " 828 | "server hasn't confirmed the that he received them."), 829 | ), 830 | Option( 831 | "unconfirmed_message_bg", 832 | "color", 833 | "", 834 | 0, 835 | 0, 836 | "default", 837 | "Background counterpart of unconfirmed_message_fg." 838 | ), 839 | Option( 840 | "untagged_code_fg", 841 | "color", 842 | "", 843 | 0, 844 | 0, 845 | "blue", 846 | ("Foreground color for code without a language specifier. " 847 | "Also used for `inline code`."), 848 | ), 849 | Option( 850 | "untagged_code_bg", 851 | "color", 852 | "", 853 | 0, 854 | 0, 855 | "default", 856 | "Background counterpart of untagged_code_fg", 857 | ), 858 | Option( 859 | "nick_prefixes", 860 | "string", 861 | "", 862 | 0, 863 | 0, 864 | "admin=lightgreen;mod=lightgreen;power=yellow", 865 | ('Colors for nick prefixes indicating power level. ' 866 | 'Format is "admin:color1;mod:color2;power:color3", ' 867 | 'where "admin" stands for admins (power level = 100), ' 868 | '"mod" stands for moderators (power level >= 50) and ' 869 | '"power" for any other power user (power level > 0). ' 870 | 'Requires restart to apply changes.'), 871 | parse_nick_prefix_colors, 872 | ), 873 | ] 874 | 875 | sections = [ 876 | ("network", network_options), 877 | ("look", look_options), 878 | ("color", color_options), 879 | ] 880 | 881 | super().__init__(sections) 882 | 883 | # The server section is essentially a section with subsections and no 884 | # options, handle that case independently. 885 | W.config_new_section( 886 | self._ptr, 887 | "server", 888 | 0, 889 | 0, 890 | "matrix_config_server_read_cb", 891 | "", 892 | "matrix_config_server_write_cb", 893 | "", 894 | "", 895 | "", 896 | "", 897 | "", 898 | "", 899 | "", 900 | ) 901 | 902 | def read(self): 903 | super().read() 904 | self.human_buffer_names = self.look.human_buffer_names 905 | 906 | def free(self): 907 | section_ptr = W.config_search_section(self._ptr, "server") 908 | W.config_section_free(section_ptr) 909 | super().free() 910 | -------------------------------------------------------------------------------- /matrix/globals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright © 2018, 2019 Damir Jelić 4 | # 5 | # Permission to use, copy, modify, and/or distribute this software for 6 | # any purpose with or without fee is hereby granted, provided that the 7 | # above copyright notice and this permission notice appear in all copies. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 12 | # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER 13 | # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF 14 | # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 15 | # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | 17 | from __future__ import unicode_literals 18 | 19 | import sys 20 | from typing import Any, Dict, Optional 21 | from logbook import Logger 22 | from collections import OrderedDict 23 | 24 | from .utf import WeechatWrapper 25 | 26 | if False: 27 | from .server import MatrixServer 28 | from .config import MatrixConfig 29 | from .uploads import Upload 30 | 31 | 32 | try: 33 | import weechat 34 | 35 | W = weechat if sys.hexversion >= 0x3000000 else WeechatWrapper(weechat) 36 | except ImportError: 37 | import matrix._weechat as weechat # type: ignore 38 | 39 | W = weechat 40 | 41 | SERVERS = dict() # type: Dict[str, MatrixServer] 42 | CONFIG = None # type: Any 43 | ENCRYPTION = True # type: bool 44 | SCRIPT_NAME = "matrix" # type: str 45 | BUFFER_NAME_PREFIX = "{}.".format(SCRIPT_NAME) # type: str 46 | TYPING_NOTICE_TIMEOUT = 4000 # 4 seconds typing notice lifetime 47 | LOGGER = Logger("weechat-matrix") 48 | UPLOADS = OrderedDict() # type: Dict[str, Upload] 49 | -------------------------------------------------------------------------------- /matrix/message_renderer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright © 2018, 2019 Damir Jelić 4 | # 5 | # Permission to use, copy, modify, and/or distribute this software for 6 | # any purpose with or without fee is hereby granted, provided that the 7 | # above copyright notice and this permission notice appear in all copies. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 12 | # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER 13 | # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF 14 | # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 15 | # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | 17 | 18 | """Module for rendering matrix messages in Weechat.""" 19 | 20 | from __future__ import unicode_literals 21 | from nio import Api 22 | from .globals import W 23 | from .colors import Formatted 24 | 25 | 26 | class Render(object): 27 | """Class collecting methods for rendering matrix messages in Weechat.""" 28 | 29 | @staticmethod 30 | def _media(url, description): 31 | return ("{del_color}<{ncolor}{desc}{del_color}>{ncolor} " 32 | "{del_color}[{ncolor}{url}{del_color}]{ncolor}").format( 33 | del_color=W.color("chat_delimiters"), 34 | ncolor=W.color("reset"), 35 | desc=description, url=url) 36 | 37 | @staticmethod 38 | def media(mxc, body, homeserver=None): 39 | """Render a mxc media URI.""" 40 | url = Api.mxc_to_http(mxc, homeserver) 41 | description = "{}".format(body) if body else "file" 42 | return Render._media(url, description) 43 | 44 | @staticmethod 45 | def encrypted_media(mxc, body, key, hash, iv, homeserver=None, mime=None): 46 | """Render a mxc media URI of an encrypted file.""" 47 | http_url = Api.encrypted_mxc_to_plumb( 48 | mxc, 49 | key, 50 | hash, 51 | iv, 52 | homeserver, 53 | mime, 54 | ) 55 | url = http_url if http_url else mxc 56 | description = "{}".format(body) if body else "file" 57 | return Render._media(url, description) 58 | 59 | @staticmethod 60 | def message(body, formatted_body): 61 | """Render a room message.""" 62 | if formatted_body: 63 | formatted = Formatted.from_html(formatted_body) 64 | return formatted.to_weechat() 65 | 66 | return body 67 | 68 | @staticmethod 69 | def redacted(censor, reason=None): 70 | """Render a redacted event message.""" 71 | reason = ( 72 | ', reason: "{reason}"'.format(reason=reason) 73 | if reason 74 | else "" 75 | ) 76 | 77 | data = ( 78 | "{del_color}<{log_color}Message redacted by: " 79 | "{censor}{log_color}{reason}{del_color}>{ncolor}" 80 | ).format( 81 | del_color=W.color("chat_delimiters"), 82 | ncolor=W.color("reset"), 83 | log_color=W.color("logger.color.backlog_line"), 84 | censor=censor, 85 | reason=reason, 86 | ) 87 | 88 | return data 89 | 90 | @staticmethod 91 | def room_encryption(nick): 92 | """Render a room encryption event.""" 93 | return "{nick} has enabled encryption in this room".format(nick=nick) 94 | 95 | @staticmethod 96 | def unknown(message_type, content=None): 97 | """Render a message of an unknown type.""" 98 | content = ( 99 | ': "{content}"'.format(content=content) 100 | if content 101 | else "" 102 | ) 103 | return "Unknown message of type {t}{c}".format( 104 | t=message_type, 105 | c=content 106 | ) 107 | 108 | @staticmethod 109 | def megolm(): 110 | """Render an undecrypted megolm event.""" 111 | return ("{del_color}<{log_color}Unable to decrypt: " 112 | "The sender's device has not sent us " 113 | "the keys for this message{del_color}>{ncolor}").format( 114 | del_color=W.color("chat_delimiters"), 115 | log_color=W.color("logger.color.backlog_line"), 116 | ncolor=W.color("reset")) 117 | 118 | @staticmethod 119 | def bad(event): 120 | """Render a malformed event of a known type""" 121 | return "Bad event received, event type: {t}".format(t=event.type) 122 | -------------------------------------------------------------------------------- /matrix/uploads.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright © 2018, 2019 Damir Jelić 4 | # 5 | # Permission to use, copy, modify, and/or distribute this software for 6 | # any purpose with or without fee is hereby granted, provided that the 7 | # above copyright notice and this permission notice appear in all copies. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 12 | # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER 13 | # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF 14 | # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 15 | # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | 17 | """Module implementing upload functionality.""" 18 | 19 | from __future__ import unicode_literals 20 | 21 | import attr 22 | import time 23 | import json 24 | from typing import Dict, Any 25 | from uuid import uuid1, UUID 26 | from enum import Enum 27 | 28 | try: 29 | from json.decoder import JSONDecodeError 30 | except ImportError: 31 | JSONDecodeError = ValueError # type: ignore 32 | 33 | from .globals import SCRIPT_NAME, SERVERS, W, UPLOADS 34 | from .utf import utf8_decode 35 | from .message_renderer import Render 36 | from matrix import globals as G 37 | from nio import Api 38 | 39 | 40 | class UploadState(Enum): 41 | created = 0 42 | active = 1 43 | finished = 2 44 | error = 3 45 | aborted = 4 46 | 47 | 48 | @attr.s 49 | class Proxy(object): 50 | ptr = attr.ib(type=str) 51 | 52 | @property 53 | def name(self): 54 | return W.infolist_string(self.ptr, "name") 55 | 56 | @property 57 | def address(self): 58 | return W.infolist_string(self.ptr, "address") 59 | 60 | @property 61 | def type(self): 62 | return W.infolist_string(self.ptr, "type_string") 63 | 64 | @property 65 | def port(self): 66 | return str(W.infolist_integer(self.ptr, "port")) 67 | 68 | @property 69 | def user(self): 70 | return W.infolist_string(self.ptr, "username") 71 | 72 | @property 73 | def password(self): 74 | return W.infolist_string(self.ptr, "password") 75 | 76 | 77 | @attr.s 78 | class Upload(object): 79 | """Class representing an upload to a matrix server.""" 80 | 81 | server_name = attr.ib(type=str) 82 | server_address = attr.ib(type=str) 83 | access_token = attr.ib(type=str) 84 | room_id = attr.ib(type=str) 85 | filepath = attr.ib(type=str) 86 | encrypt = attr.ib(type=bool, default=False) 87 | file_keys = attr.ib(type=Dict, default=None) 88 | 89 | done = 0 90 | total = 0 91 | 92 | uuid = None 93 | buffer = None 94 | upload_hook = None 95 | content_uri = None 96 | file_name = None 97 | mimetype = "?" 98 | state = UploadState.created 99 | 100 | def __attrs_post_init__(self): 101 | self.uuid = uuid1() 102 | self.buffer = "" 103 | 104 | server = SERVERS[self.server_name] 105 | 106 | proxy_name = server.config.proxy 107 | proxy = None 108 | proxies_list = None 109 | 110 | if proxy_name: 111 | proxies_list = W.infolist_get("proxy", "", proxy_name) 112 | if proxies_list: 113 | W.infolist_next(proxies_list) 114 | proxy = Proxy(proxies_list) 115 | 116 | process_args = { 117 | "arg1": self.filepath, 118 | "arg2": self.server_address, 119 | "arg3": self.access_token, 120 | "buffer_flush": "1", 121 | } 122 | 123 | arg_count = 3 124 | 125 | if self.encrypt: 126 | arg_count += 1 127 | process_args["arg{}".format(arg_count)] = "--encrypt" 128 | 129 | if not server.config.ssl_verify: 130 | arg_count += 1 131 | process_args["arg{}".format(arg_count)] = "--insecure" 132 | 133 | if proxy: 134 | arg_count += 1 135 | process_args["arg{}".format(arg_count)] = "--proxy-type" 136 | arg_count += 1 137 | process_args["arg{}".format(arg_count)] = proxy.type 138 | 139 | arg_count += 1 140 | process_args["arg{}".format(arg_count)] = "--proxy-address" 141 | arg_count += 1 142 | process_args["arg{}".format(arg_count)] = proxy.address 143 | 144 | arg_count += 1 145 | process_args["arg{}".format(arg_count)] = "--proxy-port" 146 | arg_count += 1 147 | process_args["arg{}".format(arg_count)] = proxy.port 148 | 149 | if proxy.user: 150 | arg_count += 1 151 | process_args["arg{}".format(arg_count)] = "--proxy-user" 152 | arg_count += 1 153 | process_args["arg{}".format(arg_count)] = proxy.user 154 | 155 | if proxy.password: 156 | arg_count += 1 157 | process_args["arg{}".format(arg_count)] = "--proxy-password" 158 | arg_count += 1 159 | process_args["arg{}".format(arg_count)] = proxy.password 160 | 161 | self.upload_hook = W.hook_process_hashtable( 162 | "matrix_upload", 163 | process_args, 164 | 0, 165 | "upload_cb", 166 | str(self.uuid) 167 | ) 168 | 169 | if proxies_list: 170 | W.infolist_free(proxies_list) 171 | 172 | def abort(self): 173 | pass 174 | 175 | @property 176 | def msgtype(self): 177 | # type: () -> str 178 | assert self.mimetype 179 | return Api.mimetype_to_msgtype(self.mimetype) 180 | 181 | @property 182 | def content(self): 183 | # type: () -> Dict[Any, Any] 184 | assert self.content_uri 185 | 186 | if self.encrypt: 187 | content = { 188 | "body": self.file_name, 189 | "msgtype": self.msgtype, 190 | "file": self.file_keys, 191 | } 192 | content["file"]["url"] = self.content_uri 193 | content["file"]["mimetype"] = self.mimetype 194 | 195 | # TODO thumbnail if it's an image 196 | 197 | return content 198 | 199 | return { 200 | "msgtype": self.msgtype, 201 | "body": self.file_name, 202 | "url": self.content_uri, 203 | } 204 | 205 | @property 206 | def render(self): 207 | # type: () -> str 208 | assert self.content_uri 209 | 210 | if self.encrypt: 211 | return Render.encrypted_media( 212 | self.content_uri, 213 | self.file_name, 214 | self.file_keys["key"]["k"], 215 | self.file_keys["hashes"]["sha256"], 216 | self.file_keys["iv"], 217 | mime=self.file_keys.get("mimetype"), 218 | ) 219 | 220 | return Render.media(self.content_uri, self.file_name) 221 | 222 | 223 | @attr.s 224 | class UploadsBuffer(object): 225 | """Weechat buffer showing the uploads for a server.""" 226 | 227 | _ptr = "" # type: str 228 | _selected_line = 0 # type: int 229 | uploads = UPLOADS 230 | 231 | def __attrs_post_init__(self): 232 | self._ptr = W.buffer_new( 233 | SCRIPT_NAME + ".uploads", 234 | "", 235 | "", 236 | "", 237 | "", 238 | ) 239 | W.buffer_set(self._ptr, "type", "free") 240 | W.buffer_set(self._ptr, "title", "Upload list") 241 | W.buffer_set(self._ptr, "key_bind_meta2-A", "/uploads up") 242 | W.buffer_set(self._ptr, "key_bind_meta2-B", "/uploads down") 243 | W.buffer_set(self._ptr, "localvar_set_type", "uploads") 244 | 245 | self.render() 246 | 247 | def move_line_up(self): 248 | self._selected_line = max(self._selected_line - 1, 0) 249 | self.render() 250 | 251 | def move_line_down(self): 252 | self._selected_line = min( 253 | self._selected_line + 1, 254 | len(self.uploads) - 1 255 | ) 256 | self.render() 257 | 258 | def display(self): 259 | """Display the buffer.""" 260 | W.buffer_set(self._ptr, "display", "1") 261 | 262 | def render(self): 263 | """Render the new state of the upload buffer.""" 264 | # This function is under the MIT license. 265 | # Copyright (c) 2016 Vladimir Ignatev 266 | def progress(count, total): 267 | bar_len = 60 268 | 269 | if total == 0: 270 | bar = '-' * bar_len 271 | return "[{}] {}%".format(bar, "?") 272 | 273 | filled_len = int(round(bar_len * count / float(total))) 274 | percents = round(100.0 * count / float(total), 1) 275 | bar = '=' * filled_len + '-' * (bar_len - filled_len) 276 | 277 | return "[{}] {}%".format(bar, percents) 278 | 279 | W.buffer_clear(self._ptr) 280 | header = "{}{}{}{}{}{}{}{}".format( 281 | W.color("green"), 282 | "Actions (letter+enter):", 283 | W.color("lightgreen"), 284 | " [A] Accept", 285 | " [C] Cancel", 286 | " [R] Remove", 287 | " [P] Purge finished", 288 | " [Q] Close this buffer" 289 | ) 290 | W.prnt_y(self._ptr, 0, header) 291 | 292 | for line_number, upload in enumerate(self.uploads.values()): 293 | line_color = "{},{}".format( 294 | "white" if line_number == self._selected_line else "default", 295 | "blue" if line_number == self._selected_line else "default", 296 | ) 297 | first_line = ("%s%s %-24s %s%s%s %s (%s.%s)" % ( 298 | W.color(line_color), 299 | "*** " if line_number == self._selected_line else " ", 300 | upload.room_id, 301 | "\"", 302 | upload.filepath, 303 | "\"", 304 | upload.mimetype, 305 | SCRIPT_NAME, 306 | upload.server_name, 307 | )) 308 | W.prnt_y(self._ptr, (line_number * 2) + 2, first_line) 309 | 310 | status_color = "{},{}".format("green", "blue") 311 | status = "{}{}{}".format( 312 | W.color(status_color), 313 | upload.state.name, 314 | W.color(line_color) 315 | ) 316 | 317 | second_line = ("{color}{prefix} {status} {progressbar} " 318 | "{done} / {total}").format( 319 | color=W.color(line_color), 320 | prefix="*** " if line_number == self._selected_line else " ", 321 | status=status, 322 | progressbar=progress(upload.done, upload.total), 323 | done=W.string_format_size(upload.done), 324 | total=W.string_format_size(upload.total)) 325 | 326 | W.prnt_y(self._ptr, (line_number * 2) + 3, second_line) 327 | 328 | 329 | def find_upload(uuid): 330 | return UPLOADS.get(uuid, None) 331 | 332 | 333 | def handle_child_message(upload, message): 334 | if message["type"] == "progress": 335 | upload.done = message["data"] 336 | 337 | elif message["type"] == "status": 338 | if message["status"] == "started": 339 | upload.state = UploadState.active 340 | upload.total = message["total"] 341 | upload.mimetype = message["mimetype"] 342 | upload.file_name = message["file_name"] 343 | 344 | elif message["status"] == "done": 345 | upload.state = UploadState.finished 346 | upload.content_uri = message["url"] 347 | upload.file_keys = message.get("file_keys", None) 348 | 349 | server = SERVERS.get(upload.server_name, None) 350 | 351 | if not server: 352 | return 353 | 354 | server.room_send_upload(upload) 355 | 356 | elif message["status"] == "error": 357 | upload.state = UploadState.error 358 | 359 | if G.CONFIG.upload_buffer: 360 | G.CONFIG.upload_buffer.render() 361 | 362 | 363 | @utf8_decode 364 | def upload_cb(data, command, return_code, out, err): 365 | upload = find_upload(UUID(data)) 366 | 367 | if not upload: 368 | return W.WEECHAT_RC_OK 369 | 370 | if return_code == W.WEECHAT_HOOK_PROCESS_ERROR: 371 | W.prnt("", "Error with command '%s'" % command) 372 | return W.WEECHAT_RC_OK 373 | 374 | if err != "": 375 | W.prnt("", "Error with command '%s'" % err) 376 | upload.state = UploadState.error 377 | 378 | if out != "": 379 | upload.buffer += out 380 | messages = upload.buffer.split("\n") 381 | upload.buffer = "" 382 | 383 | for m in messages: 384 | try: 385 | message = json.loads(m) 386 | except (JSONDecodeError, TypeError): 387 | upload.buffer += m 388 | continue 389 | 390 | handle_child_message(upload, message) 391 | 392 | return W.WEECHAT_RC_OK 393 | -------------------------------------------------------------------------------- /matrix/utf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2014-2016 Ryan Huber 4 | # Copyright (c) 2015-2016 Tollef Fog Heen 5 | 6 | # Permission is hereby granted, free of charge, to any person obtaining 7 | # a copy of this software and associated documentation files (the 8 | # "Software"), to deal in the Software without restriction, including 9 | # without limitation the rights to use, copy, modify, merge, publish, 10 | # distribute, sublicense, and/or sell copies of the Software, and to 11 | # permit persons to whom the Software is furnished to do so, subject to 12 | # the following conditions: 13 | 14 | # The above copyright notice and this permission notice shall be 15 | # included in all copies or substantial portions of the Software. 16 | 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 21 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 22 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 23 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | 25 | from __future__ import unicode_literals 26 | 27 | import sys 28 | 29 | # pylint: disable=redefined-builtin 30 | from builtins import bytes, str 31 | from functools import wraps 32 | 33 | if sys.version_info.major == 3 and sys.version_info.minor >= 3: 34 | from collections.abc import Iterable, Mapping 35 | else: 36 | from collections import Iterable, Mapping 37 | 38 | # These functions were written by Trygve Aaberge for wee-slack and are under a 39 | # MIT License. 40 | # More info can be found in the wee-slack repository under the commit: 41 | # 5e1c7e593d70972afb9a55f29d13adaf145d0166, the repository can be found at: 42 | # https://github.com/wee-slack/wee-slack 43 | 44 | 45 | class WeechatWrapper(object): 46 | def __init__(self, wrapped_class): 47 | self.wrapped_class = wrapped_class 48 | 49 | # Helper method used to encode/decode method calls. 50 | def wrap_for_utf8(self, method): 51 | def hooked(*args, **kwargs): 52 | result = method(*encode_to_utf8(args), **encode_to_utf8(kwargs)) 53 | # Prevent wrapped_class from becoming unwrapped 54 | if result == self.wrapped_class: 55 | return self 56 | return decode_from_utf8(result) 57 | 58 | return hooked 59 | 60 | # Encode and decode everything sent to/received from weechat. We use the 61 | # unicode type internally in wee-slack, but has to send utf8 to weechat. 62 | def __getattr__(self, attr): 63 | orig_attr = self.wrapped_class.__getattribute__(attr) 64 | if callable(orig_attr): 65 | return self.wrap_for_utf8(orig_attr) 66 | return decode_from_utf8(orig_attr) 67 | 68 | # Ensure all lines sent to weechat specify a prefix. For lines after the 69 | # first, we want to disable the prefix, which is done by specifying a 70 | # space. 71 | def prnt_date_tags(self, buffer, date, tags, message): 72 | message = message.replace("\n", "\n \t") 73 | return self.wrap_for_utf8(self.wrapped_class.prnt_date_tags)( 74 | buffer, date, tags, message 75 | ) 76 | 77 | 78 | def utf8_decode(function): 79 | """ 80 | Decode all arguments from byte strings to unicode strings. Use this for 81 | functions called from outside of this script, e.g. callbacks from weechat. 82 | """ 83 | 84 | @wraps(function) 85 | def wrapper(*args, **kwargs): 86 | 87 | # Don't do anything if we're python 3 88 | if sys.hexversion >= 0x3000000: 89 | return function(*args, **kwargs) 90 | 91 | return function(*decode_from_utf8(args), **decode_from_utf8(kwargs)) 92 | 93 | return wrapper 94 | 95 | 96 | def decode_from_utf8(data): 97 | if isinstance(data, bytes): 98 | return data.decode("utf-8") 99 | if isinstance(data, str): 100 | return data 101 | elif isinstance(data, Mapping): 102 | return type(data)(map(decode_from_utf8, data.items())) 103 | elif isinstance(data, Iterable): 104 | return type(data)(map(decode_from_utf8, data)) 105 | return data 106 | 107 | 108 | def encode_to_utf8(data): 109 | if isinstance(data, str): 110 | return data.encode("utf-8") 111 | if isinstance(data, bytes): 112 | return data 113 | elif isinstance(data, Mapping): 114 | return type(data)(map(encode_to_utf8, data.items())) 115 | elif isinstance(data, Iterable): 116 | return type(data)(map(encode_to_utf8, data)) 117 | return data 118 | -------------------------------------------------------------------------------- /matrix/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright © 2018, 2019 Damir Jelić 4 | # Copyright © 2018, 2019 Denis Kasak 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for 7 | # any purpose with or without fee is hereby granted, provided that the 8 | # above copyright notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 13 | # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER 14 | # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF 15 | # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 16 | # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | 18 | from __future__ import unicode_literals, division 19 | 20 | import time 21 | from typing import Any, Dict, List 22 | 23 | from .globals import W 24 | 25 | if False: 26 | from .server import MatrixServer 27 | 28 | 29 | def key_from_value(dictionary, value): 30 | # type: (Dict[str, Any], Any) -> str 31 | return list(dictionary.keys())[list(dictionary.values()).index(value)] 32 | 33 | 34 | def server_buffer_prnt(server, string): 35 | # type: (MatrixServer, str) -> None 36 | assert server.server_buffer 37 | buffer = server.server_buffer 38 | now = int(time.time()) 39 | W.prnt_date_tags(buffer, now, "", string) 40 | 41 | 42 | def tags_from_line_data(line_data): 43 | # type: (str) -> List[str] 44 | tags_count = W.hdata_get_var_array_size( 45 | W.hdata_get("line_data"), line_data, "tags_array" 46 | ) 47 | 48 | tags = [ 49 | W.hdata_string( 50 | W.hdata_get("line_data"), line_data, "%d|tags_array" % i 51 | ) 52 | for i in range(tags_count) 53 | ] 54 | 55 | return tags 56 | 57 | 58 | def create_server_buffer(server): 59 | # type: (MatrixServer) -> None 60 | buffer_name = "server.{}".format(server.name) 61 | server.server_buffer = W.buffer_new( 62 | buffer_name, "server_buffer_cb", server.name, "", "" 63 | ) 64 | 65 | server_buffer_set_title(server) 66 | W.buffer_set(server.server_buffer, "short_name", server.name) 67 | W.buffer_set(server.server_buffer, "localvar_set_type", "server") 68 | W.buffer_set( 69 | server.server_buffer, "localvar_set_nick", server.config.username 70 | ) 71 | W.buffer_set(server.server_buffer, "localvar_set_server", server.name) 72 | W.buffer_set(server.server_buffer, "localvar_set_channel", server.name) 73 | 74 | server.buffer_merge() 75 | 76 | 77 | def server_buffer_set_title(server): 78 | # type: (MatrixServer) -> None 79 | if server.numeric_address: 80 | ip_string = " ({address})".format(address=server.numeric_address) 81 | else: 82 | ip_string = "" 83 | 84 | title = ("Matrix: {address}:{port}{ip}").format( 85 | address=server.address, port=server.config.port, ip=ip_string 86 | ) 87 | 88 | W.buffer_set(server.server_buffer, "title", title) 89 | 90 | 91 | def server_ts_to_weechat(timestamp): 92 | # type: (float) -> int 93 | date = int(timestamp / 1000) 94 | return date 95 | 96 | 97 | def strip_matrix_server(string): 98 | # type: (str) -> str 99 | return string.rsplit(":", 1)[0] 100 | 101 | 102 | def shorten_sender(sender): 103 | # type: (str) -> str 104 | return strip_matrix_server(sender)[1:] 105 | 106 | 107 | def string_strikethrough(string): 108 | return "".join(["{}\u0336".format(c) for c in string]) 109 | 110 | 111 | def string_color_and_reset(string, color): 112 | """Color string with color, then reset all attributes.""" 113 | 114 | lines = string.split('\n') 115 | lines = ("{}{}{}".format(W.color(color), line, W.color("reset")) 116 | for line in lines) 117 | return "\n".join(lines) 118 | 119 | 120 | def string_color(string, color): 121 | """Color string with color, then reset the color attribute.""" 122 | 123 | lines = string.split('\n') 124 | lines = ("{}{}{}".format(W.color(color), line, W.color("resetcolor")) 125 | for line in lines) 126 | return "\n".join(lines) 127 | 128 | 129 | def color_pair(color_fg, color_bg): 130 | """Make a color pair from a pair of colors.""" 131 | 132 | if color_bg: 133 | return "{},{}".format(color_fg, color_bg) 134 | else: 135 | return color_fg 136 | 137 | 138 | def text_block(text, margin=0): 139 | """ 140 | Pad block of text with whitespace to form a regular block, optionally 141 | adding a margin. 142 | """ 143 | 144 | # add vertical margin 145 | vertical_margin = margin // 2 146 | text = "{}{}{}".format( 147 | "\n" * vertical_margin, 148 | text, 149 | "\n" * vertical_margin 150 | ) 151 | 152 | lines = text.split("\n") 153 | longest_len = max(len(l) for l in lines) + margin 154 | 155 | # pad block and add horizontal margin 156 | text = "\n".join( 157 | "{pre}{line}{post}".format( 158 | pre=" " * margin, 159 | line=l, 160 | post=" " * (longest_len - len(l))) 161 | for l in lines) 162 | 163 | return text 164 | 165 | 166 | def colored_text_block(text, margin=0, color_pair=""): 167 | """ Like text_block, but also colors it.""" 168 | return string_color_and_reset(text_block(text, margin=margin), color_pair) 169 | 170 | def parse_redact_args(args): 171 | args = args.strip() 172 | 173 | had_example_text = False 174 | 175 | try: 176 | event_id, rest = args.split("|", 1) 177 | had_example_text = True 178 | except ValueError: 179 | try: 180 | event_id, rest = args.split(" ", 1) 181 | except ValueError: 182 | event_id, rest = (args, "") 183 | 184 | if had_example_text: 185 | rest = rest.lstrip() 186 | reason = None # until it has been correctly determined 187 | if rest[0] == '"': 188 | escaped = False 189 | for i in range(1, len(rest)): 190 | if escaped: 191 | escaped = False 192 | elif rest[i] == "\\": 193 | escaped = True 194 | elif rest[i] == '"': 195 | reason = rest[i+1:] 196 | break 197 | else: 198 | reason = rest 199 | 200 | event_id = event_id.strip() 201 | if reason: 202 | reason = reason.strip() 203 | # The reason might be an empty string, set it to None if so 204 | else: 205 | reason = None 206 | 207 | return event_id, reason 208 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "matrix" 3 | version = "0.3.0" 4 | license = "ISC" 5 | description = "Weechat protocol script for Matrix." 6 | authors = ["Damir Jelić "] 7 | packages = [ 8 | { include = "matrix" }, 9 | { include = "contrib/*.py", format = "sdist" }, 10 | { include = "main.py", format = "sdist" }, 11 | ] 12 | 13 | [tool.poetry.dependencies] 14 | python = "^3.6" 15 | pyOpenSSL = "^19.1.0" 16 | webcolors = "^1.11.1" 17 | atomicwrites = "^1.3.0" 18 | future = { version = "^0.18.2", python = "<3.2" } 19 | attrs = "^19.3.0" 20 | logbook = "^1.5.3" 21 | pygments = "^2.6.1" 22 | matrix-nio = { version = "^0.18.7", extras = [ "e2e" ] } 23 | python-magic = { version = "^0.4.15", optional = true } 24 | aiohttp = { version = "^3.6.2", optional = true } 25 | requests = { version = "^2.23.0", optional = true } 26 | typing = { version = "^3.7.4", python = "<3.5" } 27 | 28 | [tool.poetry.extras] 29 | matrix_decrypt = ["requests"] 30 | matrix_sso_helper = ["aiohttp"] 31 | matrix_upload = ["python-magic", "requests"] 32 | 33 | [build-system] 34 | requires = ["poetry>=0.12"] 35 | build-backend = "poetry.masonry.api" 36 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyOpenSSL 2 | typing ; python_version < "3.5" 3 | webcolors 4 | future; python_version < "3.2" 5 | atomicwrites 6 | attrs 7 | logbook 8 | pygments 9 | matrix-nio[e2e]>=0.21.0 10 | aiohttp ; python_version >= "3.5" 11 | python-magic 12 | requests 13 | -------------------------------------------------------------------------------- /tests/buffer_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | from matrix.buffer import WeechatChannelBuffer 6 | from matrix.utils import parse_redact_args 7 | 8 | 9 | class TestClass(object): 10 | def test_buffer(self): 11 | b = WeechatChannelBuffer("test_buffer_name", "example.org", "alice") 12 | assert b 13 | 14 | def test_buffer_print(self): 15 | b = WeechatChannelBuffer("test_buffer_name", "example.org", "alice") 16 | b.message("alice", "hello world", 0, 0) 17 | assert b 18 | 19 | def test_redact_args_parse(self): 20 | args = '$81wbnOYZllVZJcstsnXpq7dmugA775-JT4IB-uPT680|"Hello world" No specific reason' 21 | event_id, reason = parse_redact_args(args) 22 | assert event_id == '$81wbnOYZllVZJcstsnXpq7dmugA775-JT4IB-uPT680' 23 | assert reason == 'No specific reason' 24 | 25 | args = '$15677776791893pZSXx:example.org|"Hello world" No reason at all' 26 | event_id, reason = parse_redact_args(args) 27 | assert event_id == '$15677776791893pZSXx:example.org' 28 | assert reason == 'No reason at all' 29 | 30 | args = '$15677776791893pZSXx:example.org No reason at all' 31 | event_id, reason = parse_redact_args(args) 32 | assert event_id == '$15677776791893pZSXx:example.org' 33 | assert reason == 'No reason at all' 34 | 35 | args = '$81wbnOYZllVZJcstsnXpq7dmugA775-JT4IB-uPT680 No specific reason' 36 | event_id, reason = parse_redact_args(args) 37 | assert event_id == '$81wbnOYZllVZJcstsnXpq7dmugA775-JT4IB-uPT680' 38 | assert reason == 'No specific reason' 39 | 40 | args = '$81wbnOYZllVZJcstsnXpq7dmugA775-JT4IB-uPT680' 41 | event_id, reason = parse_redact_args(args) 42 | assert event_id == '$81wbnOYZllVZJcstsnXpq7dmugA775-JT4IB-uPT680' 43 | assert reason == None 44 | 45 | args = '$15677776791893pZSXx:example.org' 46 | event_id, reason = parse_redact_args(args) 47 | assert event_id == '$15677776791893pZSXx:example.org' 48 | assert reason == None 49 | 50 | args = ' ' 51 | event_id, reason = parse_redact_args(args) 52 | assert event_id == '' 53 | assert reason == None 54 | 55 | args = '$15677776791893pZSXx:example.org|"Hello world"' 56 | event_id, reason = parse_redact_args(args) 57 | assert event_id == '$15677776791893pZSXx:example.org' 58 | assert reason == None 59 | 60 | args = '$15677776791893pZSXx:example.org|"Hello world' 61 | event_id, reason = parse_redact_args(args) 62 | assert event_id == '$15677776791893pZSXx:example.org' 63 | assert reason == None 64 | 65 | args = '$15677776791893pZSXx:example.org "Hello world"' 66 | event_id, reason = parse_redact_args(args) 67 | assert event_id == '$15677776791893pZSXx:example.org' 68 | assert reason == '"Hello world"' 69 | -------------------------------------------------------------------------------- /tests/color_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | import webcolors 6 | from collections import OrderedDict 7 | from hypothesis import given 8 | from hypothesis.strategies import sampled_from, text, characters 9 | 10 | from matrix.colors import (G, Formatted, FormattedString, 11 | color_html_to_weechat, color_weechat_to_html) 12 | from matrix._weechat import MockConfig 13 | 14 | G.CONFIG = MockConfig() 15 | 16 | html_prism = ("Test") 18 | 19 | weechat_prism = ( 20 | 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" 21 | ) 22 | 23 | first_16_html_colors = list(webcolors.HTML4_HEX_TO_NAMES.values()) 24 | 25 | 26 | def test_prism(): 27 | formatted = Formatted.from_html(html_prism) 28 | assert formatted.to_weechat() == weechat_prism 29 | 30 | 31 | @given(sampled_from(first_16_html_colors)) 32 | def test_color_conversion(color_name): 33 | hex_color = color_weechat_to_html(color_html_to_weechat(color_name)) 34 | new_color_name = webcolors.hex_to_name(hex_color, spec='html4') 35 | assert new_color_name == color_name 36 | 37 | 38 | def test_handle_strikethrough_first(): 39 | valid_result = '\x1b[038;5;1mf̶o̶o̶\x1b[039m' 40 | 41 | d1 = OrderedDict([('fgcolor', 'red'), ('strikethrough', True)]) 42 | d2 = OrderedDict([('strikethrough', True), ('fgcolor', 'red'), ]) 43 | f1 = Formatted([FormattedString('foo', d1)]) 44 | f2 = Formatted([FormattedString('foo', d2)]) 45 | 46 | assert f1.to_weechat() == valid_result 47 | assert f2.to_weechat() == valid_result 48 | 49 | 50 | def test_normalize_spaces_in_inline_code(): 51 | """Normalize spaces in inline code blocks. 52 | 53 | Strips leading and trailing spaces and compress consecutive infix spaces. 54 | """ 55 | valid_result = '\x1b[0m* a *\x1b[00m' 56 | 57 | formatted = Formatted.from_input_line('` * a * `') 58 | assert formatted.to_weechat() == valid_result 59 | 60 | 61 | @given( 62 | text(alphabet=characters(min_codepoint=32, 63 | blacklist_characters="*_`\\")) 64 | .map(lambda s: '*' + s)) 65 | def test_unpaired_prefix_asterisk_without_space_is_literal(text): 66 | """An unpaired asterisk at the beginning of the line, without a space 67 | after it, is considered literal. 68 | """ 69 | formatted = Formatted.from_input_line(text) 70 | assert text.strip() == formatted.to_weechat() 71 | 72 | 73 | def test_input_line_color(): 74 | formatted = Formatted.from_input_line("\x0304Hello") 75 | assert "\x1b[038;5;9mHello\x1b[039m" == formatted.to_weechat() 76 | assert "Hello" == formatted.to_html() 77 | 78 | def test_input_line_bold(): 79 | formatted = Formatted.from_input_line("\x02Hello") 80 | assert "\x1b[01mHello\x1b[021m" == formatted.to_weechat() 81 | assert "Hello" == formatted.to_html() 82 | 83 | def test_input_line_underline(): 84 | formatted = Formatted.from_input_line("\x1FHello") 85 | assert "\x1b[04mHello\x1b[024m" == formatted.to_weechat() 86 | assert "Hello" == formatted.to_html() 87 | 88 | def test_input_line_markdown_emph(): 89 | formatted = Formatted.from_input_line("*Hello*") 90 | assert "\x1b[03mHello\x1b[023m" == formatted.to_weechat() 91 | assert "Hello" == formatted.to_html() 92 | 93 | def test_input_line_markdown_bold(): 94 | formatted = Formatted.from_input_line("**Hello**") 95 | assert "\x1b[01mHello\x1b[021m" == formatted.to_weechat() 96 | assert "Hello" == formatted.to_html() 97 | 98 | def test_input_line_markdown_various(): 99 | inp = "**bold* bold *bital etc* bold **bold** * *italic*" 100 | formatted = Formatted.from_input_line(inp) 101 | assert "bold* bold " \ 102 | "bital etc bold **bold" \ 103 | " * italic" \ 104 | == formatted.to_html() 105 | 106 | def test_input_line_markdown_various2(): 107 | inp = "norm** `code **code *code` norm `norm" 108 | formatted = Formatted.from_input_line(inp) 109 | assert "norm** code **code *code norm `norm" \ 110 | == formatted.to_html() 111 | 112 | def test_input_line_backslash(): 113 | def convert(s): return Formatted.from_input_line(s).to_html() 114 | assert "pre italic* ital norm" == convert("pre *italic\\* ital* norm") 115 | assert "*norm* norm" == convert("\\*norm* norm") 116 | assert "*ital" == convert("*\\*ital*") 117 | assert "C:\\path" == convert("`C:\\path`") 118 | assert "with`tick" == convert("`with\\`tick`") 119 | assert "`un`matched" == convert("`un\\`matched") 120 | assert "bold *bital norm" == convert("**bold *\\*bital*** norm") 121 | 122 | def test_conversion(): 123 | formatted = Formatted.from_input_line("*Hello*") 124 | formatted2 = Formatted.from_html(formatted.to_html()) 125 | formatted.to_weechat() == formatted2.to_weechat() 126 | -------------------------------------------------------------------------------- /tests/http_parser_test.py: -------------------------------------------------------------------------------- 1 | import html.entities 2 | 3 | from hypothesis import given 4 | from hypothesis.strategies import sampled_from 5 | 6 | from matrix.colors import MatrixHtmlParser 7 | 8 | try: 9 | # python 3 10 | html_entities = [(name, char, ord(char)) 11 | for name, char in html.entities.html5.items() 12 | if not name.endswith(';')] 13 | except AttributeError: 14 | # python 2 15 | html_entities = [(name, unichr(codepoint), codepoint) 16 | for name, codepoint 17 | in html.entities.name2codepoint.items()] 18 | 19 | 20 | @given(sampled_from(html_entities)) 21 | def test_html_named_entity_parsing(entitydef): 22 | name = entitydef[0] 23 | character = entitydef[1] 24 | parser = MatrixHtmlParser() 25 | assert parser.unescape('&{};'.format(name)) == character 26 | 27 | 28 | @given(sampled_from(html_entities)) 29 | def test_html_numeric_reference_parsing(entitydef): 30 | character = entitydef[1] 31 | num = entitydef[2] 32 | parser = MatrixHtmlParser() 33 | assert parser.unescape('&#{};'.format(num)) == character 34 | 35 | 36 | @given(sampled_from(html_entities)) 37 | def test_html_entityref_reconstruction_from_name(entitydef): 38 | name = entitydef[0] 39 | parser = MatrixHtmlParser() 40 | parser.handle_entityref(name) 41 | s = parser.get_substrings() 42 | assert s[0].text == parser.unescape('&{};'.format(name)) and len(s) == 1 43 | 44 | 45 | @given(sampled_from(html_entities)) 46 | def test_html_charref_reconstruction_from_name(entitydef): 47 | num = entitydef[2] 48 | parser = MatrixHtmlParser() 49 | parser.handle_charref(num) 50 | s = parser.get_substrings() 51 | assert s[0].text == parser.unescape('&#{};'.format(num)) and len(s) == 1 52 | 53 | 54 | def test_parsing_of_escaped_brackets(): 55 | p = MatrixHtmlParser() 56 | p.feed('
<faketag>
') 57 | s = p.get_substrings() 58 | assert s[0].text == '' and len(s) == 1 59 | -------------------------------------------------------------------------------- /tests/server_test.py: -------------------------------------------------------------------------------- 1 | from matrix.server import MatrixServer 2 | from matrix._weechat import MockConfig 3 | import matrix.globals as G 4 | 5 | G.CONFIG = MockConfig() 6 | 7 | class TestClass(object): 8 | def test_address_parsing(self): 9 | homeserver = MatrixServer._parse_url("example.org", 8080) 10 | assert homeserver.hostname == "example.org" 11 | assert homeserver.geturl() == "https://example.org:8080" 12 | 13 | homeserver = MatrixServer._parse_url("example.org/_matrix", 80) 14 | assert homeserver.hostname == "example.org" 15 | assert homeserver.geturl() == "https://example.org:80/_matrix" 16 | 17 | homeserver = MatrixServer._parse_url( 18 | "https://example.org/_matrix", 80 19 | ) 20 | assert homeserver.hostname == "example.org" 21 | assert homeserver.geturl() == "https://example.org:80/_matrix" 22 | --------------------------------------------------------------------------------