From 30080f1de3239c0275d492a741d39346108381b0 Mon Sep 17 00:00:00 2001 From: jowj Date: Sat, 4 Jan 2020 17:27:57 -0600 Subject: [PATCH] Add python/matrix stuff. --- .weechat/python/matrix.py | 610 ++++++ .weechat/python/matrix/__init__.py | 0 .../__pycache__/__init__.cpython-37.pyc | Bin 0 -> 134 bytes .../__pycache__/bar_items.cpython-37.pyc | Bin 0 -> 4377 bytes .../matrix/__pycache__/buffer.cpython-37.pyc | Bin 0 -> 40579 bytes .../matrix/__pycache__/colors.cpython-37.pyc | Bin 0 -> 22142 bytes .../__pycache__/commands.cpython-37.pyc | Bin 0 -> 36158 bytes .../__pycache__/completion.cpython-37.pyc | Bin 0 -> 6574 bytes .../matrix/__pycache__/config.cpython-37.pyc | Bin 0 -> 18465 bytes .../matrix/__pycache__/globals.cpython-37.pyc | Bin 0 -> 729 bytes .../matrix/__pycache__/server.cpython-37.pyc | Bin 0 -> 40633 bytes .../matrix/__pycache__/uploads.cpython-37.pyc | Bin 0 -> 8981 bytes .../matrix/__pycache__/utf.cpython-37.pyc | Bin 0 -> 2616 bytes .../matrix/__pycache__/utils.cpython-37.pyc | Bin 0 -> 4678 bytes .weechat/python/matrix/_weechat.py | 248 +++ .weechat/python/matrix/bar_items.py | 202 ++ .weechat/python/matrix/buffer.py | 1763 +++++++++++++++ .weechat/python/matrix/colors.py | 1178 ++++++++++ .weechat/python/matrix/commands.py | 1836 ++++++++++++++++ .weechat/python/matrix/completion.py | 366 ++++ .weechat/python/matrix/config.py | 807 +++++++ .weechat/python/matrix/globals.py | 48 + .weechat/python/matrix/server.py | 1900 +++++++++++++++++ .weechat/python/matrix/uploads.py | 399 ++++ .weechat/python/matrix/utf.py | 117 + .weechat/python/matrix/utils.py | 168 ++ 26 files changed, 9642 insertions(+) create mode 100644 .weechat/python/matrix.py create mode 100644 .weechat/python/matrix/__init__.py create mode 100644 .weechat/python/matrix/__pycache__/__init__.cpython-37.pyc create mode 100644 .weechat/python/matrix/__pycache__/bar_items.cpython-37.pyc create mode 100644 .weechat/python/matrix/__pycache__/buffer.cpython-37.pyc create mode 100644 .weechat/python/matrix/__pycache__/colors.cpython-37.pyc create mode 100644 .weechat/python/matrix/__pycache__/commands.cpython-37.pyc create mode 100644 .weechat/python/matrix/__pycache__/completion.cpython-37.pyc create mode 100644 .weechat/python/matrix/__pycache__/config.cpython-37.pyc create mode 100644 .weechat/python/matrix/__pycache__/globals.cpython-37.pyc create mode 100644 .weechat/python/matrix/__pycache__/server.cpython-37.pyc create mode 100644 .weechat/python/matrix/__pycache__/uploads.cpython-37.pyc create mode 100644 .weechat/python/matrix/__pycache__/utf.cpython-37.pyc create mode 100644 .weechat/python/matrix/__pycache__/utils.cpython-37.pyc create mode 100644 .weechat/python/matrix/_weechat.py create mode 100644 .weechat/python/matrix/bar_items.py create mode 100644 .weechat/python/matrix/buffer.py create mode 100644 .weechat/python/matrix/colors.py create mode 100644 .weechat/python/matrix/commands.py create mode 100644 .weechat/python/matrix/completion.py create mode 100644 .weechat/python/matrix/config.py create mode 100644 .weechat/python/matrix/globals.py create mode 100644 .weechat/python/matrix/server.py create mode 100644 .weechat/python/matrix/uploads.py create mode 100644 .weechat/python/matrix/utf.py create mode 100644 .weechat/python/matrix/utils.py diff --git a/.weechat/python/matrix.py b/.weechat/python/matrix.py new file mode 100644 index 0000000..db71a70 --- /dev/null +++ b/.weechat/python/matrix.py @@ -0,0 +1,610 @@ +# -*- coding: utf-8 -*- + +# Weechat Matrix Protocol Script +# Copyright © 2018 Damir Jelić +# +# Permission to use, copy, modify, and/or distribute this software for +# any purpose with or without fee is hereby granted, provided that the +# above copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER +# RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from __future__ import unicode_literals + +import os +import socket +import ssl +import textwrap +# pylint: disable=redefined-builtin +from builtins import str +from itertools import chain +# pylint: disable=unused-import +from typing import Any, AnyStr, Deque, Dict, List, Optional, Set, Text, Tuple + +import logbook +import OpenSSL.crypto as crypto +from future.utils import bytes_to_native_str as n +from logbook import Logger, StreamHandler +from nio import RemoteProtocolError, RemoteTransportError, TransportType + +from matrix import globals as G +from matrix.bar_items import ( + init_bar_items, + matrix_bar_item_buffer_modes, + matrix_bar_item_lag, + matrix_bar_item_name, + matrix_bar_item_plugin, + matrix_bar_nicklist_count, + matrix_bar_typing_notices_cb +) +from matrix.buffer import room_buffer_close_cb, room_buffer_input_cb +# Weechat searches for the registered callbacks in the scope of the main script +# file, import the callbacks here so weechat can find them. +from matrix.commands import (hook_commands, hook_page_up, + matrix_command_buf_clear_cb, matrix_command_cb, + matrix_command_pgup_cb, matrix_invite_command_cb, + matrix_join_command_cb, matrix_kick_command_cb, + matrix_me_command_cb, matrix_part_command_cb, + matrix_redact_command_cb, matrix_topic_command_cb, + matrix_olm_command_cb, matrix_devices_command_cb, + matrix_room_command_cb, matrix_uploads_command_cb, + matrix_upload_command_cb, matrix_send_anyways_cb) +from matrix.completion import (init_completion, matrix_command_completion_cb, + matrix_debug_completion_cb, + matrix_message_completion_cb, + matrix_olm_device_completion_cb, + matrix_olm_user_completion_cb, + matrix_server_command_completion_cb, + matrix_server_completion_cb, + matrix_user_completion_cb, + matrix_own_devices_completion_cb, + matrix_room_completion_cb) +from matrix.config import (MatrixConfig, config_log_category_cb, + config_log_level_cb, config_server_buffer_cb, + matrix_config_reload_cb, config_pgup_cb) +from matrix.globals import SCRIPT_NAME, SERVERS, W, MAX_EVENTS +from matrix.server import (MatrixServer, create_default_server, + matrix_config_server_change_cb, + matrix_config_server_read_cb, + matrix_config_server_write_cb, matrix_timer_cb, + send_cb, matrix_load_users_cb, + matrix_partial_sync_cb) +from matrix.utf import utf8_decode +from matrix.utils import server_buffer_prnt, server_buffer_set_title + +from matrix.uploads import UploadsBuffer, upload_cb + +# yapf: disable +WEECHAT_SCRIPT_NAME = SCRIPT_NAME +WEECHAT_SCRIPT_DESCRIPTION = "matrix chat plugin" # type: str +WEECHAT_SCRIPT_AUTHOR = "Damir Jelić " # type: str +WEECHAT_SCRIPT_VERSION = "0.1" # type: str +WEECHAT_SCRIPT_LICENSE = "ISC" # type: str +# yapf: enable + + +logger = Logger("matrix-cli") + + +def print_certificate_info(buff, sock, cert): + cert_pem = ssl.DER_cert_to_PEM_cert(sock.getpeercert(True)) + + x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert_pem) + + public_key = x509.get_pubkey() + + key_type = ("RSA" if public_key.type() == crypto.TYPE_RSA else "DSA") + key_size = str(public_key.bits()) + sha256_fingerprint = x509.digest(n(b"SHA256")) + sha1_fingerprint = x509.digest(n(b"SHA1")) + signature_algorithm = x509.get_signature_algorithm() + + key_info = ("key info: {key_type} key {bits} bits, signed using " + "{algo}").format( + key_type=key_type, bits=key_size, + algo=n(signature_algorithm)) + + validity_info = (" Begins on: {before}\n" + " Expires on: {after}").format( + before=cert["notBefore"], after=cert["notAfter"]) + + rdns = chain(*cert["subject"]) + subject = ", ".join(["{}={}".format(name, value) for name, value in rdns]) + + rdns = chain(*cert["issuer"]) + issuer = ", ".join(["{}={}".format(name, value) for name, value in rdns]) + + subject = "subject: {sub}, serial number {serial}".format( + sub=subject, serial=cert["serialNumber"]) + + issuer = "issuer: {issuer}".format(issuer=issuer) + + fingerprints = (" SHA1: {}\n" + " SHA256: {}").format(n(sha1_fingerprint), + n(sha256_fingerprint)) + + wrapper = textwrap.TextWrapper( + initial_indent=" - ", subsequent_indent=" ") + + message = ("{prefix}matrix: received certificate\n" + " - certificate info:\n" + "{subject}\n" + "{issuer}\n" + "{key_info}\n" + " - period of validity:\n{validity_info}\n" + " - fingerprints:\n{fingerprints}").format( + prefix=W.prefix("network"), + subject=wrapper.fill(subject), + issuer=wrapper.fill(issuer), + key_info=wrapper.fill(key_info), + validity_info=validity_info, + fingerprints=fingerprints) + + W.prnt(buff, message) + + +def wrap_socket(server, file_descriptor): + # type: (MatrixServer, int) -> None + sock = None # type: socket.socket + + temp_socket = socket.fromfd(file_descriptor, socket.AF_INET, + socket.SOCK_STREAM) + + # fromfd() duplicates the file descriptor, we can close the one we got from + # weechat now since we use the one from our socket when calling hook_fd() + os.close(file_descriptor) + + # For python 2.7 wrap_socket() doesn't work with sockets created from an + # file descriptor because fromfd() doesn't return a wrapped socket, the bug + # was fixed for python 3, more info: https://bugs.python.org/issue13942 + # pylint: disable=protected-access,unidiomatic-typecheck + if type(temp_socket) == socket._socket.socket: + # pylint: disable=no-member + sock = socket._socketobject(_sock=temp_socket) + else: + sock = temp_socket + + # fromfd() duplicates the file descriptor but doesn't retain it's blocking + # non-blocking attribute, so mark the socket as non-blocking even though + # weechat already did that for us + sock.setblocking(False) + + message = "{prefix}matrix: Doing SSL handshake...".format( + prefix=W.prefix("network")) + W.prnt(server.server_buffer, message) + + ssl_socket = server.ssl_context.wrap_socket( + sock, do_handshake_on_connect=False, + server_hostname=server.address) # type: ssl.SSLSocket + + server.socket = ssl_socket + + try_ssl_handshake(server) + + +@utf8_decode +def ssl_fd_cb(server_name, file_descriptor): + server = SERVERS[server_name] + + if server.ssl_hook: + W.unhook(server.ssl_hook) + server.ssl_hook = None + + try_ssl_handshake(server) + + return W.WEECHAT_RC_OK + + +def try_ssl_handshake(server): + sock = server.socket + + while True: + try: + sock.do_handshake() + + cipher = sock.cipher() + cipher_message = ("{prefix}matrix: Connected using {tls}, and " + "{bit} bit {cipher} cipher suite.").format( + prefix=W.prefix("network"), + tls=cipher[1], + bit=cipher[2], + cipher=cipher[0]) + W.prnt(server.server_buffer, cipher_message) + + cert = sock.getpeercert() + if cert: + print_certificate_info(server.server_buffer, sock, cert) + + finalize_connection(server) + + return True + + except ssl.SSLWantReadError: + hook = W.hook_fd(server.socket.fileno(), 1, 0, 0, "ssl_fd_cb", + server.name) + server.ssl_hook = hook + + return False + + except ssl.SSLWantWriteError: + hook = W.hook_fd(server.socket.fileno(), 0, 1, 0, "ssl_fd_cb", + server.name) + server.ssl_hook = hook + + return False + + except (ssl.SSLError, ssl.CertificateError, socket.error) as error: + try: + str_error = error.reason if error.reason else "Unknown error" + except AttributeError: + str_error = str(error) + + message = ("{prefix}Error while doing SSL handshake" + ": {error}").format( + prefix=W.prefix("network"), error=str_error) + + server_buffer_prnt(server, message) + + server_buffer_prnt( + server, ("{prefix}matrix: disconnecting from server..." + ).format(prefix=W.prefix("network"))) + + server.disconnect() + return False + + +@utf8_decode +def receive_cb(server_name, file_descriptor): + server = SERVERS[server_name] + + while True: + try: + data = server.socket.recv(4096) + except ssl.SSLWantReadError: + break + except socket.error as error: + errno = "error" + str(error.errno) + " " if error.errno else "" + str_error = error.strerror if error.strerror else "Unknown error" + str_error = errno + str_error + + message = ("{prefix}Error while reading from " + "socket: {error}").format( + prefix=W.prefix("network"), error=str_error) + + server_buffer_prnt(server, message) + + server_buffer_prnt( + server, ("{prefix}matrix: disconnecting from server..." + ).format(prefix=W.prefix("network"))) + + server.disconnect() + + return W.WEECHAT_RC_OK + + if not data: + server_buffer_prnt( + server, + "{prefix}matrix: Error while reading from socket".format( + prefix=W.prefix("network"))) + server_buffer_prnt( + server, ("{prefix}matrix: disconnecting from server..." + ).format(prefix=W.prefix("network"))) + + server.disconnect() + break + + try: + server.client.receive(data) + except (RemoteTransportError, RemoteProtocolError) as e: + server.error(str(e)) + server.disconnect() + break + + response = server.client.next_response(MAX_EVENTS) + + # Check if we need to send some data back + data_to_send = server.client.data_to_send() + + if data_to_send: + server.send(data_to_send) + + if response: + server.handle_response(response) + break + + return W.WEECHAT_RC_OK + + +def finalize_connection(server): + hook = W.hook_fd( + server.socket.fileno(), + 1, + 0, + 0, + "receive_cb", + server.name + ) + + server.fd_hook = hook + server.connected = True + server.connecting = False + server.reconnect_delay = 0 + + negotiated_protocol = (server.socket.selected_alpn_protocol() or + server.socket.selected_npn_protocol()) + + if negotiated_protocol == "h2": + server.transport_type = TransportType.HTTP2 + else: + server.transport_type = TransportType.HTTP + + data = server.client.connect(server.transport_type) + server.send(data) + + server.login() + + +@utf8_decode +def connect_cb(data, status, gnutls_rc, sock, error, ip_address): + # pylint: disable=too-many-arguments,too-many-branches + status_value = int(status) # type: int + server = SERVERS[data] + + if status_value == W.WEECHAT_HOOK_CONNECT_OK: + file_descriptor = int(sock) # type: int + server.numeric_address = ip_address + server_buffer_set_title(server) + + wrap_socket(server, file_descriptor) + + return W.WEECHAT_RC_OK + + elif status_value == W.WEECHAT_HOOK_CONNECT_ADDRESS_NOT_FOUND: + server.error('{address} not found'.format(address=ip_address)) + + elif status_value == W.WEECHAT_HOOK_CONNECT_IP_ADDRESS_NOT_FOUND: + server.error('IP address not found') + + elif status_value == W.WEECHAT_HOOK_CONNECT_CONNECTION_REFUSED: + server.error('Connection refused') + + elif status_value == W.WEECHAT_HOOK_CONNECT_PROXY_ERROR: + server.error('Proxy fails to establish connection to server') + + elif status_value == W.WEECHAT_HOOK_CONNECT_LOCAL_HOSTNAME_ERROR: + server.error('Unable to set local hostname') + + elif status_value == W.WEECHAT_HOOK_CONNECT_GNUTLS_INIT_ERROR: + server.error('TLS init error') + + elif status_value == W.WEECHAT_HOOK_CONNECT_GNUTLS_HANDSHAKE_ERROR: + server.error('TLS Handshake failed') + + elif status_value == W.WEECHAT_HOOK_CONNECT_MEMORY_ERROR: + server.error('Not enough memory') + + elif status_value == W.WEECHAT_HOOK_CONNECT_TIMEOUT: + server.error('Timeout') + + elif status_value == W.WEECHAT_HOOK_CONNECT_SOCKET_ERROR: + server.error('Unable to create socket') + else: + server.error('Unexpected error: {status}'.format(status=status_value)) + + server.disconnect(reconnect=True) + return W.WEECHAT_RC_OK + + +@utf8_decode +def room_close_cb(data, buffer): + W.prnt("", + "Buffer '%s' will be closed!" % W.buffer_get_string(buffer, "name")) + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_unload_cb(): + for server in SERVERS.values(): + server.config.free() + + G.CONFIG.free() + + # for server in SERVERS.values(): + # server.store_olm() + + return W.WEECHAT_RC_OK + + +def autoconnect(servers): + for server in servers.values(): + if server.config.autoconnect: + server.connect() + + +def debug_buffer_close_cb(data, buffer): + G.CONFIG.debug_buffer = "" + return W.WEECHAT_RC_OK + + +def server_buffer_cb(server_name, buffer, input_data): + message = ("{}{}: this buffer is not a room buffer!").format( + W.prefix("error"), SCRIPT_NAME) + W.prnt(buffer, message) + return W.WEECHAT_RC_OK + + +class WeechatHandler(StreamHandler): + def __init__(self, level=logbook.NOTSET, format_string=None, filter=None, + bubble=False): + StreamHandler.__init__( + self, + object(), + level, + format_string, + None, + filter, + bubble + ) + + def write(self, item): + buf = "" + + if G.CONFIG.network.debug_buffer: + if not G.CONFIG.debug_buffer: + G.CONFIG.debug_buffer = W.buffer_new( + "Matrix Debug", "", "", "debug_buffer_close_cb", "") + + buf = G.CONFIG.debug_buffer + + W.prnt(buf, item) + + +def buffer_switch_cb(_, _signal, buffer_ptr): + """Do some buffer operations when we switch buffers. + + This function is called every time we switch a buffer. The pointer of + the new buffer is given to us by weechat. + + If it is one of our room buffers we check if the members for the room + aren't fetched and fetch them now if they aren't. + + Read receipts are send out from here as well. + """ + for server in SERVERS.values(): + if buffer_ptr == server.server_buffer: + return W.WEECHAT_RC_OK + + if buffer_ptr not in server.buffers.values(): + continue + + room_buffer = server.find_room_from_ptr(buffer_ptr) + if not room_buffer: + continue + + if room_buffer.should_send_read_marker: + event_id = room_buffer.last_event_id + + # A buffer may not have any events, in that case no event id is + # here returned + if event_id: + server.room_send_read_marker( + room_buffer.room.room_id, event_id) + room_buffer.last_read_event = event_id + + if room_buffer.members_fetched: + return W.WEECHAT_RC_OK + + room_id = room_buffer.room.room_id + server.get_joined_members(room_id) + + break + + return W.WEECHAT_RC_OK + + +def typing_notification_cb(data, signal, buffer_ptr): + """Send out typing notifications if the user is typing. + + This function is called every time the input text is changed. + It checks if we are on a buffer we own, and if we are sends out a typing + notification if the room is configured to send them out. + """ + for server in SERVERS.values(): + room_buffer = server.find_room_from_ptr(buffer_ptr) + if room_buffer: + server.room_send_typing_notice(room_buffer) + return W.WEECHAT_RC_OK + + if buffer_ptr == server.server_buffer: + return W.WEECHAT_RC_OK + + return W.WEECHAT_RC_OK + + +def buffer_command_cb(data, _, command): + """Override the buffer command to allow switching buffers by short name.""" + command = command[7:].strip() + + buffer_subcommands = ["list", "add", "clear", "move", "swap", "cycle", + "merge", "unmerge", "hide", "unhide", "renumber", + "close", "notify", "localvar", "set", "get"] + + if not command: + return W.WEECHAT_RC_OK + + command_words = command.split() + + if len(command_words) >= 1: + if command_words[0] in buffer_subcommands: + return W.WEECHAT_RC_OK + + elif command_words[0].startswith("*"): + return W.WEECHAT_RC_OK + + try: + int(command_words[0]) + return W.WEECHAT_RC_OK + except ValueError: + pass + + room_buffers = [] + + for server in SERVERS.values(): + room_buffers.extend(server.room_buffers.values()) + + sorted_buffers = sorted( + room_buffers, + key=lambda b: b.weechat_buffer.number + ) + + for room_buffer in sorted_buffers: + buffer = room_buffer.weechat_buffer + + if command in buffer.short_name: + displayed = W.current_buffer() == buffer._ptr + + if displayed: + continue + + W.buffer_set(buffer._ptr, 'display', '1') + return W.WEECHAT_RC_OK_EAT + + return W.WEECHAT_RC_OK + + +if __name__ == "__main__": + if W.register(WEECHAT_SCRIPT_NAME, WEECHAT_SCRIPT_AUTHOR, + WEECHAT_SCRIPT_VERSION, WEECHAT_SCRIPT_LICENSE, + WEECHAT_SCRIPT_DESCRIPTION, 'matrix_unload_cb', ''): + + if not W.mkdir_home("matrix", 0o700): + message = ("{prefix}matrix: Error creating session " + "directory").format(prefix=W.prefix("error")) + W.prnt("", message) + + handler = WeechatHandler() + handler.format_string = "{record.channel}: {record.message}" + handler.push_application() + + # TODO if this fails we should abort and unload the script. + G.CONFIG = MatrixConfig() + G.CONFIG.read() + + hook_commands() + init_bar_items() + init_completion() + + W.hook_command_run("/buffer", "buffer_command_cb", "") + W.hook_signal("buffer_switch", "buffer_switch_cb", "") + W.hook_signal("input_text_changed", "typing_notification_cb", "") + + if not SERVERS: + create_default_server(G.CONFIG) + + autoconnect(SERVERS) diff --git a/.weechat/python/matrix/__init__.py b/.weechat/python/matrix/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.weechat/python/matrix/__pycache__/__init__.cpython-37.pyc b/.weechat/python/matrix/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f4696f6cec07fdee8211e0685e1029d1149a97f8 GIT binary patch literal 134 zcmZ?b<>g`k0=v65u^{>}h=2h`Aj1KOi&=m~3PUi1CZpdeZ`P)%AYw zqvredx})It@E;F8{QFlF<)75K_~{_NizodJ8m2HcQbww$stVJX9%&=pGX!l!`l#Yn zMy6+qekHO-wr8tKU^A0hA1g0ZuL{~`RY5zT9aa-`4YbSZf_6bS*p{H{ptsqMpc`zL z?R~6xTWtSGXPq>6^5OkOWvN?r9#qT#wYrb^2~XvfNRfnR#|Ba6LfWMuGED!x9|<-;q0rUPR7IO zc{hm?zBrI?zt>-!$DL8I$g9U4j;%)F$TNxT^;>#g5z?M5USQ=0347)^n)JiC<#76_ z+vd>LchM+%L%psV zs*S&f?y8Q)Z-84`n!d8M^dHcfzAP>M(Wa&O6WCH88vG7ZV~xRr^U4xfkQN-83!CXf zE3>6l=l0Nn-L)e{;G;(PDCQ3?<}+a9LZ03XXn>5%tP(HwvJSl1z)O`?mqMD*HLtFq zx#b*sTWQwGCaF)B@Y6K%YHN&Hy}*(LP)mDl+S$EgNSfpvu<|ADez7rUZQw&re$mo} z60cg$5GEAO%s5}1FIo+OIbMS_chTfEYz*1~pJ|pix`Uv5;t!HEqfO_#Jpjh%Niy<# z91XCM8`R|uQcxl?cUhQ@qt2Q5R$iw;0n(i{kfv~;xDV9t&@dr@?@_Z)%@t~{QbP#O zYhq0~ncpThsS`+)4WTICzl<<}d5olAM??4`Tp7Bfnwp{R>W=7@h|~DjAXVax7Pjz+ z$nskd(@#sh{dyB`>yZVhKx7%3Lu+WCRQSuRxrf@?U_G#{UM6FIV5s%||~ueEh@rb1M$A(}bURE^Txa#(p|TPG1Aavx(1= zIKXQ73?=~9s{S8X;k1_L(0qxo%P85qj1q|c7<1BFXk-xDRsj(NpsNT+4OLScfQzm1 z7REQ=VSpjv@kbET-<9zAY!e<7PL{d|Kjz#V8Vjqhz>L z45?kZkf^UzK3tP&6f#@sM{C?|!8M0F=}XMq<)5vM?a>%hm-};jr52#UyQLlSx3KHh zl}izT(=iAi%d0`$6?~n%szzHD-L%*c)7NqXj0sg<*hAu`~X!lhY$X=Q|(BE5{1TbGeiL@y!S#pIMUh`1sEV`!$j zji({nZOz5Iq4DoPri2YeT1xW*Hd7D;8WA5V(+8Qh)J5fT7tul|s8IjJY_TY@~q$($Vm>9GaQm<6;Z`_S1uAzi1Q57Ooj}Z z8ax~Inc#X?FDUC2p7IR0NL1WfM{jY{D7H+mXuf0&` z?uo(wJg+fh?y|~tPt%n7xQ$Zd6voXrVpxJ3kfHYCFH7Tz&DNMuT zniJF_X|p@w9F88HHM6rZ*lnjNe;*9OwAq`)-3*T11l{dKuuSv#tXWiw%>cmw^TH7= zBOQwb)6+0R)xnwxzfktHU6za!v9CK1XfF?2Bp*!2QP>S>4|0um^MgUKx#9Fe)8+jZ zpMiq9nPE~fTYsqOWNFTwD0vT0B z0rLraX|AJK=ih=>SwEG*mA?a?cf0)TLS>v}VK?x{Jm`f}eh)Gz(-TfH^7g1R^*6<2 z9T3F*Y=BH1#I5?9{pylSRpj<8`Ucv#LH%4GrG3w$g`n+!#mbF+q<;7KQ*1S*Z&Iwn zvk)Z>#79Tf$k*Uw2Blba8?~9M?rVmIbo)KXcWKhauZ){LaAe%1yX;C=WzS%hwI1%S zYdzdv*Lt|WuJv$(UF$V2dQ3r-+&Y}?-x2qsRuTP+%Mjg7BtmvK$EoNPmE_DR22fy# z+F^F-oW_Gg>DdN<|QHm3p?QJTNnTv8vIa16OBe+OVS)RKknBw4c&3Ga{ zUMGnLD#EP1ouz*PFU9{96a}%6{tOgCny8%RyP@(&7(Hxl=Z^39CfS4szRxxA#JO;) zj%2|=hLhXSafjzrRg1fwI3{s4B1wuED?UhLr9KF6CL()!iNU NE%&N>)7^Jl{{bBcb4vgK literal 0 HcmV?d00001 diff --git a/.weechat/python/matrix/__pycache__/buffer.cpython-37.pyc b/.weechat/python/matrix/__pycache__/buffer.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b54fe6066d368a26cc74210e13e95a1f744ddf3f GIT binary patch literal 40579 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 ZrkI_uVbeTP7@N3hV*kX4W5Iv!e*@~z0wDkZ literal 0 HcmV?d00001 diff --git a/.weechat/python/matrix/__pycache__/colors.cpython-37.pyc b/.weechat/python/matrix/__pycache__/colors.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..653177a83eeea92b95ffe84235d4c1a30c46dbca GIT binary patch literal 22142 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 JvNz)u{tYE&{ht5; literal 0 HcmV?d00001 diff --git a/.weechat/python/matrix/__pycache__/config.cpython-37.pyc b/.weechat/python/matrix/__pycache__/config.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..61be38972779d08b868a04abb18aa1f593314deb GIT binary patch literal 18465 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 literal 0 HcmV?d00001 diff --git a/.weechat/python/matrix/__pycache__/server.cpython-37.pyc b/.weechat/python/matrix/__pycache__/server.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..36b6b8a5ee9639b5a668eb6b6d77ad5e9e3fd4f9 GIT binary patch literal 40633 zcmcJ&36vbyc^=wby-rV0&x*kgHn9-{f&&l)Nf3fS>?F7hAk2Uu+oD=Mb8BY0(aYdg z4T)*YP!d5)lr2h#q{x&*+cOHEo}xI8VkdI!7rmpCbidG}FXdxP9+^SjyORlj(Os?@lT&{^iLaxa|Qm&~&3fEXQUF#|I)G~#P zydSUj*0P1H#1qxNTCR|*^%weU1BHRwU}3N}R2Zra7lvyig^}8Z!UoBgtd7<;7B)&e zRoztET-aROQrJ@4TG(3KR@heCUf5pSQP@$tr*Ka#U&z<)E!uW(;&UtwQutT0yFU)W!}zi@x;K;b~`fx-i|@xplRVBuixP~lMR!NP;J z!-d1OhYAnXjuej69xgmwd!+D4?a{)cwWEcjwZ{sN)s7X8)jm-8K<#+pINFs~J=G7^ zP81%Oc&2)?cB*iycDiu7cBXJf(t4|BYv&5*YUc~*YfluOkhE;|MD5AKleG(l3$>>T zPt~3-JY9RH@Qgg`t3F$sC`?E^SG`!fRJc_8P~k(h%Z1C5)?b~hO%wv51Typuo_asc5&8HBlZUSIeTi>+8mj+=B!&*;iFGP)CM*B zT11W7=WfLcCERaRn{dC$p2q!*J)t(MEw4pxMGFe?t!kUZZN#^$9TJ~K{2rB;_#EQ* zs+|&_M|_vsE%CD2qxQZQDO8YlpV}vB*OWCG85?_t5Ih$31{Uk(nTE29)v{~rQq`Fl zv%Fqq&z2Uet}4&CUP9T|7j0zCmfS|IJX3sGBkNe&i=C=3d5N=p>c!78Yx?4XTW-`# zRWJTb*>SzhM5$)0sl|n=?e(5DWlF}xl{)gF%=ncn7tW6DMp+AGFY~19E}W^B?Yip? zJ=2&eRWIp=+n8xo&uQJzUf(l~xpMuo?JP9vj_swxSmJV{QFFWjUMB6j3Lo?ZDW-tz zfqD%lJlWOTQ7xKCFxAH zRIY_d{b%i$$}_eTCX8HJP$kzk$zkf|vv$=EQk_$a?tJ)YL&u{qxo=9B>KGF3PAx6i z-Uz$=ESg@LYpbmPTzy6_$?TX$k4P80Q~NGfYg2mBaZRQ^xr8R{w@Nl1n@5)URkI%N{U@P-_W&^ z>)Oie52KT=F4yP0p|j`CpStqQRPof*)a47OuS}hr#JlN5U0o{N;a`&K* z@#Q4aGWhl4m&LCSzZ`!3{Gq=w{O#nsjI9kKX5mTtc;xzbu44DZ$nKi(dP|GMMF5RQjy%*HzJv$exf2Vzabu}D&h0JVma`Ks=+gMfX*X$yP!X-w=8cm~E&Av1@ zU-4r>WytRLB)w2_oR=F~HBVmhW72%{IKQvnaP4EP#svyy9X4?VaEUsAB7zn(ag1~T z_pGASnY-$b_p>q;&7%gP=4EjGKEFI)uG;x(V-EZDT)tdiclNwt&a?(96KDWZtCp6^ zAOBD^;#m(KYCei>uC$cjhqoQ(QO;O?+Qxj@7>Fg7i^{^cdE@syX|Bz|noXv1aJl2f zYUMhD(hV;m*nKSJ#T~mk>-C!M$}D*qzi3vPG7WXSl;0Tr5jM2fZ$F#z*CN_M+Pe=N zoNv_ZgO!F;F3let_m|eeg(Y{sQ9o#g>!7J;d|}B;7mMY3*)109c-i?`1Q9D4#b3;d zw*6W0c-tMG#4Rg2fM5F`)7t;m$+K=b?eC=H*=W+rMB~w1Ebf;U$6f0$$jztC4U@8M zo{{Mv5Bj2?MK|;WgAXyd%wUqi6oV@ao@4Mlg93veVekS1ZqeyrCCw12@gJ|;MP%m~ zK*Wu#V(+L}Yr-J{Ge#%IVg`J+hTtP`gKgZlec*8P4Bj1!>KZOGUnX#I>&2Pz-y!CJ z14Wh1!H&-H>BQIoXN>b*EP9z@vDQ$FRmQW$;`PN+)jZLQcwZky;PnR}t{V%sc9+c1 z$f(GGtTPr~f4C;iik5NFH&`Y?tSw->^)m?AA7>MSbTqw?jduRUquH3`V&}tu9L|&Y zIn(HFH~GNL72A2-PH+JX6#;&1^x0yRt=~@ zr1g=3!!@Ty)COGp)u`Ht>wwy%Hsd;|wy3SR4k>K0xDKlw>Kjt$`?ZS0b z?N)nm-Kh4e`*7W)_Ng&kH>>^Xeq6VxFQ^C9_-oO^R&`JvLdrJvpgN4}cJ)PdL_Lfr zJJciUQKa0Xj;hCS&8xqxj;Rme$-U~h`XEwvsuSvQTz9E2sgvpyp6pgXp-!tai0x5l z)j2%dtIn$@aJ^4GsV?BUPd%lc#&t|Rqn^ceznV}NalKz%QXj(gfO=C+swtH4fcmm} zPF+Q8T>YeaK@|`?sD4C!7`YCq7u82_eNYwEM{zx@N@^O{ht!NxxE@iqn#J{DHK*or zeMFU21=mN_HC4s+sH&+tu8*mPTEO*~x~?>?A5e~RaXqdU)l0a3P`#{f;CezWsV1(E ztD9;W*OO{Ry@Km0^)dBvTu-Y{s8?}4qi(6!a6PMjRDBZHbLvy-bzIM@Ppcop^$GRk z>NB`LsoqeZ#r1;voVtzcQ|d3N&*S>EdI$^juXtIr-JoXa}xGTIN%Ph4z2OcVYwdx zDj8ngK$3G#P|A`?8EvINpfM7UxErr!^v@a*g%MB4`fbKm4r$Rn z66WRUy()j6Ec89dREfdASVH2}*ioIkNibnh0hvKdA|Q5mRv95R%sX;bwXsQiOvKjz9Px zkscX4s%>1n6lteoS$RD^O@nA$~nj|0ZKDAA2r&v>Ovj-o{^q7-XPQJ9ZACz4fjF~l9?Ll zKwAkHEc>39At_*b?LS@NcpLn5P=_{|aBNiIGBwwo zj&l$P>`;hIP(ikea@$A#1jVyZ2K0H^qJ*_|)e z$`Jb0=ZYqumh-OJd#Td^9_D>>d%jxs~~DkO_IhnzyeohJ}+BbDeXgaBCOF)*i5VN}6TuUXD96|0aHO@Yae zs>B=76$^K1;2=Dua8_`Dmoy}5`P;z6%|~x8XnVGNW5pyM%L{PlW$4I17}&-|#xRdD z#Nupd2s3TU87U$$1qf;x>jAUofI}>~^Ip6HS(jiGL$zgU*YG+(1FN6ItN%6v$P)ylm`lV>L+%s1WqoejN^Ci{+Orl}&8$Tz1=3R@ z3F@V!=0lOok>|~licd7-H&<@1G&AF^{W7Y@vIfe(fmRqcsB2ihjR#(`?94(A;Kji_ z)V(w$m8uE}v6s~3XczRW$TXIa8TNXZQ&+X;p-HTK@zRy2m0@lni|SQI10vc_P@pxQWH1BeCYjj1P_Xg%f|7U}~0a z^GL_?US|!A9|!t@;_n0^K>>g6<~@X#`_0q8&A1mNm}a`X{T&2fYBlX0qM~p`5YtlM zBm;305e2O3YlI-uB6QBB0L_u**tH~AiRyVYvJz35WotzCb5I6=Qea=;m^EtpdnexE z7%UQZ{r;Pu`2YUy=S=jc-VUR0z8Xf~`dlm8N`3RQt<={MeK(r-qT`r@b%`cU7Sv0*MimF0$7v;I*^$K#IZ&VdXa0g9o4VafT zm6)tQ$&sVTFk;11fl!y%WCAQrJZ&`}dH+-0Ua-)9cg#3vRH_3w4y;69$LPKe{PY~i z?(3GUr07KZjC)ZVi1h!#TVBqTZY$IKR)^m0IX-8DgI>@leldUpV9bZ!f4jr?e^`65 z|3qHL4&BjStfSvU1JO>xs25d8_=wNU>dZht9k8}ekDKR!86O&C-+(7jPfN2VN# zH!6!o!L$xKIhMI;GOT3Ikb%{;ma@w{aH!auna^{(&)r4~ksX7HG;ny06rYNMi0Q)uy$oj16s6Msp2f@0tHyJ~FCu}jph&d(44CU`56BO@Uz~I)}bWu*B$1G&b8pLK$QWdfe;h|`z zF<1fwd;?2@>i#uuKTa@u29XFDwRAil9f)P3Lm(02Boy#VLt>D#;#PY50CH?XJe{E4 zCL7CQSr37#hZKVH3J_{c+E2g-e}ZaA>?M2ox?wbRsjL+DZ5Io7X#0~f=i z=46%36@8_;zKtg^OgVRXs(AU_hpt?>eD19NJkxpw8kU6r26_^KTR=^FJ@UfD#ffuX zpI@Lqw_j#Wzsi6>YgQhqO%UM1q*JM=HG?XHF6`ZBIC@`|^=}9b`;aXH`4iy5|HW}7 z8Hw3N46{wR06Cepnp-;1Vn<=&uE`B9oZ}pBIFlTCUqA$^8m4Akivi1y4NC-oceGYS zlMQV|i2pf2fsyWZbYu(L(Tl426Igp4FJneQ(}5<;wTb2~dK+4@wX)2jTszh7xoWs}Ovxrq)mvC|OJ3Fuj&s zNy17aQUtUGB@o_JViIyT2-kXrn#@NdE2(It`3goSOUPeNRr>TdRni6BTIst*ZY{MD z7LF>tl156u{uPF}?*ToVUhdf#S?N)k$^cq1sCw6;plP$P6v?<)OUs#C5XUj5`dT?u z?hWjgg!t8A)+nQZR=kpWK4KIR%b@J9krG}AS|w%l-&w}{DH|i2Kr3U{cU)ziBT^?u zVQgT+Xb3bnbn3$)fS#G2(!YRP{aprJR?Xa%`n7uF3$H%c3 z^UdZ1-7mdo3&u9+&*3cy-|f1N0q_hG7=%U1)l%KPN{dY?z+j9N<<=0#!z=+}77C%+ z*q>pr%7D#=(L-rkFg(UeB%}2Pv=YGoX#~<=!=Fn>3GrU0)iEzuoGrr;wA`eP8rEH} z!4*$4U!23{W!#2KIXb8bFBjO2p*3?9vr1~5%`{Wghdvc((}Ri&eNsC_^kU@Ly@b=S z5|KLJI z#1^RaH+A5_wz>I>t8x%kJ|0$Sb@T8Y6B^cVvtC~ipH7i0vr8Dc=HT9&&e`^jU=F>YY;k#ypBOg zumJp=BM9FAAYcH1Acd8PuR8$m(Nq0PsH(ktdNW51VuL%j;W%6V^g8_;?0_rg`d{8F zqL;srUI}iPT&Gh*9i0+%5%vn%?@BCSlOD4sAfnYcLJ^3>K=77eU)Z%iiDiF;RdWGA zlxQ_ON0L}CT$-$t`2;>5K?I_$P+g*85Rpb*GWD`qigp|X=4xUs;vB{`DccyuHW15? zb#DxzE;-nfL~o6)M3#@gpNyrtR|=b=-$3A{{LKjF!k`JoGAJz5rs|V!F!TC@LN3mrNKnmD%1n#IA~90s6;L`OOo`P&Ls#OIxE6s=DhW4f z6oZl{0a_#qO-DG47_j8Tl>S{5-F*1{4I6D1Z@Sv|WgZ!}}hCr*{B+Y8*C7az%>;k~e2%VsUId&3iiF_s(ev#}K8o%ut@k4Mqa? zY9dvH>I<}=$OV0=B1RIl3k< zdovUiowR?)Yg6HRjEheuh{cHr z>vdfQCqt2aI5=4hPPJojuu_YCs4b@whT*_{zmyJp9d}4>46$8*ie-r{YjUwJwAUWMmrS~*)>R89xXRcU7?7JbJmCQI4f2)^(BEPp z>*MQ;afb9?XYjKO{sx1eWAHZ_w6^h(l9bgUYb#Kq-@^*Hfp?=}*8F1xq2qYSbp+xJ zq5PRELIX}TW}tCH%T$4y3amL0hCtt)8lcvYg=>x7AaTH@?fh2+1hh)j&^Z+gnpWs5 zXu1HcER@WG)Z?q1Fz93feBeXmjg&qu*R-QB?LYjD9$i-{c{k%MyS>!35u__w=S|dx zlMSi|f5eQrHEN%zSpxs0-&Vcg-QtiRXZ>3Gz_X#n_yI3KN|AdbF2)w%9if|&R-!~& z@JA}(1{dYaScL9+pox`G@0eMsQ- zd@~=sd4-ZX<_}EO{dcCye~95WMqT|iV>V#St!Ad1$^1SrHe+|<^fN843d~tx27c@& zTeET#MpG-{wiq|`;)|F@W8+JyiQx_db1hne4|l8Jy3jlcFe8?EfiYPn0??;;`)LF; zrvkGjjW>K2Wic8D3?g1u9{Jm^7#IiePEaq1nb-PUj@}Nq|0d!7EOJJ`&=8WT{BFep z@fx9gC^is-hz&6aR$>sh;mThiK~Kk;BOQB=->4AyQC*9lp@;?QPl(R|ZqU#G93Wz` z%)au4R1 zq8j8NE5E>(1d@^l^o0p~fKUT^E20lFSPzJf;=!L1h<2j{ZeUoZSe^r*e?~;9o`cMr zfHfi;RW8=-WNxG(A$)O<;!l>yHbi8`K~H0rg)hhQ5Q;EZv@tUpDWjWkOc#91wY#?Y zF|>IU)%d1Npwv2%hhNzG3->_Ss^#chlxBSXQsuq`SUrw5ufJ%$oEU=!HZ6``Ls9L^ zSBxOMICWG|G%rFQvd)NmeI2E2VL#F+C<2Q|NTIuNQAg&mM;+^!@@x=%{)z(%f{d4I zE?ikS4;P{!3s!L;E}-vRk`)7tFS?*WPVOs-+Ck}6cJlMyzxUn5mPX?#Mbr%*ubv9?CNI;>Lgwj0kAl6T^hX!j8q;I))t}VY-qNi|U0% zYKO2c^@GxMNyL=2^~~#)L}MgK%UQMLWke|@rp$NmWxu>f7TZ*@FYG7wH1JQIre9r| z-)B#WHh)-8P5WWY3ss_YQ=dU!+qVL8kxt26`E( z5r=o7OMKt$)DsxQ(;Y4Btu7T z0OqU;bPTKwwf&Mg|KKBU$?ucuj{d7CvSVM8StKUVaDlQe9pDX05!CAk#hmAWZ9-Ad zoP%i%^ySn~(8vxu)s7BA^Ns>KR#G^Y<0z0T34}AmornRkbKIx}4@RwR2c}3g*8J`y zx=yW_g29Hq20stsnP}LAy;Q~-uRQA?&t&rczf*e7ITqeAQ5slQ`=G`dJ0>TdnIZ*2 zvj!^=9)j+u12zVWRn<`l3!3J+?YcYO@gP94ZN*DczN~m&T-)ZTusLdNIuX84rzZnq zCjvWD9QBYF0qbELLU!TpWUDq)B@g|<5S^RPLzpdqmfm;A^BZI+WdCX_<^@hTd86PFBE1l%Y&^d#ReKWN$$^ z4fX-X&E@D?oOd*RfJr4m1tye~ap`G^C1C@QbdfIsVxGTtZ)>pS}<%Ez$aOFS9Bkq9O2^{v3rWQf7 zO;>HD=P-L7PiH8wK8^aj(SuFv;K6sepPYvYL)OnOWEEH-{UV`^Ovczofln_NoM!N8 zszkXCI!uIbUJOQ0M!YwQ6^Ic6m_q?eLo{;|#*04TCC^-(IDg>@kqmm9{QAsD^2C{G zm!os6m=@*w*U>f&p%AQ#Om2e_&F1T;kN`BxBF2mxI*UaEZ~{l^$59ew%py1tTv_C6 zQpsI6z>Tei)KO@KOX(O2jHC=l#q&-$doJh0NS%SQbJ644jK(y=K1lc+k->d}@@;)O%CYZyr7dj2+h?{to zyBCft>+^Uk?VOjCXa$}-D9>L7&_f~zzG5Yg(jGx+y>3=Y`=Tk$?L&?nl}P00VRwxD zn_GN6tU;fTm?Na{?lJ6+;Q5AS*ip}MnWtP4MRV!Hc9b=xZY5y{f|DbaP0MlN$~I${ zfDaZ(0MYKTMDwV>-~^5P+m^VND8V}bEhK=?Yd2={S1w3Y+}tS#azd^M^F$bm9{>CwPSU0Ps6I*XYGV!rkLz75 zhy)Zju0#OqXCT(;n_=?jvIoLtu%yysH8LSz@y94i!P2x^%y@9JT=-2 z4NFi=w;*kx1@@-kpCP<+XOAQZ-e&*7 zVm+{2^i3X}DQvGlhl&kE49gkHV#m2kWB5YtLV!ht>{{YzHyPXwi|Ctp`YYk)o5H98 z2M`N@#_D9Z)~WX0Ty2~KH||gQ3@I)Cqfnt2zcctbUq^s(0M+G2UyUy9WL!j*0YjaD z4HxAe*j6FkH^z595)>O$SZ`o0zDm8_Szv<{a{si+jU3oo zk@rohN30M*52sd=RCw{3FJ+P1UiEc>!O?J1e6X04L7RfHDA%B_V2pfzgT#j!Y*|zs zaT!9h(5U0_GBC+>ps>UF*Rixg=`tAt`sc$LX;H&6H=T+?&2uKd$!Vb!B9hJ+aTq3r z$0RMHY=qGEz^Eq!pcmoI-8g^21n(wm<2*neP7$XkMB2ZK8z8=jAkU~X;95Y)m>%j5 zRrD>mL_$2i7SYErh|n2g2w}zo88}ot$0IP|Y4ld7?m6ei{fCI-STx#*^E+xJ=`5-TM@(3U1>~Gr z=9Vz9oRY}|eYKofBXZ7Q0(&WsuXCW!A!v>^^xU`+i;mC0n-IWt-mO(d^y%e7qyrmL zwdBwI`DUs%4y#`<&nUs*>Z!3I!%4S5B8@{v3z+A+mn}QRup}>qbg6=~Xg2>{EGERm zf_`;_Pao!k5XWlPryT@=)tFENFELemB#T*SicU;g=<}B#0nc&t%Tf-@7Qk{5^Vuvy zaDZF?ln^GUGfkuk^J9<-S-=`mC6T}oAj=@nB+QgU)VU7x)n07Ke5Ft=2{=ImtBCNl zGi7P|EzF*{+o85=$hOudc+0V4**)JG(B15(kB)lrkGc+MtK8izpo z68ALj*-(c-z%Z8&HhA9|6IfL-gDin%n<-;`ap&jmCnp2i7(v?G2k*k)l4s=G=dWFBXb z;c3urag%rv#kc@no@)X&6{ak&eFd^g6!qzC14#|hZl*7$}a1&%}hwGTtvz;@*G z=Vo8a$)V%3=fSLrhcd&9lZ#oeAW`gkso6OhL%7i}kqIy+(_Dr#3~0~iZIRd4BbFR* zQ!u6m#)^=;7!u3i32qk<&;(8kgB46u(xT7!7zSt9XK*y|7tX*+n}ovC3oIgG8acWS z!B(7|^VUtDo@>#C`ukYMg0<*;t2&Ezk~kFq)Lx-akY(^(FQPq# zJ~{#myXE!r)-Rxo@)NYU6X?Xrqp(O{20I}LIbbXr%n)B?K-ke0Ru<{ay&WvfT?d|% zBmaO3TY}3!hx({jwtJt(6|vwCQz3mGRi0r#h#DhkdzVprKE&p_Ht!q2LTVg_qJ)DK=yV*(r{t z;QZ5wu%$**ja?e}ABXqgeaDF3NCpbg2N5Ry6SN75X&xKJj_zyNlST;jRTO9zgyD`T z{}q-)9IzT$)cX=24^iCM`SvsHY7P}d&`}=C;Bk6Llshfb?tCxoI(CkhJur`k;6y6O zr;4!w#>8;b!+BolwQ1bMr?lcjDuhvMVgT8emZ{*JL07(bC2P`TSYQ%)G0^*7sl$bz zf*=L9AJ9B4f&zu(iIqG!xQ0HEhqId!95d3!RZgPG(J#A7)bHeB?2ZkIcz@g&tb|JH zR!L)hnlsv^Fs6qC-z70D)LAY3hsavUZud%N}_zR`aBQZkVd9j7Yf;UR( zY1pZfgFS)VHIJzgZ3l+UKf$+z1SA6OLWtgGGv&oLZrL#dWOIPBcm>Wj#56{13OiBa zU0?1PBXV?H&d#?H@KpGxtXYs}%6cumV!duvaK@5j_Gz3At$F`5 z)4n^N=HQ2+-^KaxTR4VO@HL0>cwg`0N9LmVm7dV4tqXikk zw5j=U2XL=5d#!Wah8P!RMRUiMy0f@IGce$WfT|&d1~Cv08o(x`1*yV)fRFY?G$P0+ z;CkEMi}2H{d?Ta_^yhdhEX{4kzJxv)v_TPX2V}p^=OJX@IW)qy46oDG21jSVjLtwR zYYpIRC2U7vEkZb_ERzheLE>@+>(V97Sf|hqxF^gDZXg40Hd}xFhubV++-zE{Z7N|D zV68Eve9z1IwfV}ljxi7vAxped1mJD#du>$kS^8tzXFZ{8>i9ehg%q+)Y z^_3yUxn8--{!wn6RD-Yq1s-CbWx2y!#C_&a&IFh(IcZJ(-y&Ex>~Dryg2x9W7-LT% z^qU_(mOmTj)zH|{B|27{lmrgMJj`D>YgiS8e#!(Km4<1Zk!OHS@{=~)3iZz-c% zTuEie9#9IPi@nZZ42TM)tMBqCm{FNU9sI2g)0^nwkVa);D)OS z#-~E9&%;tx6GN!vqgHjo{_rHb)iV9f3P zqoKdasT3jBVb1462&mCJ!kdQ~;Kvgr)b5E1AvUQm^PNvH5c!*c#INzT3*vr>X`e*k z4bf?UzchWngZlFT)2WW3TVIe^OL3N*fK zb)?{226WU2fhuuihDaOlp+u8%HFD#qv?}YjYM?k0W=P_}o!JC}k#<%%7|hVh5Z+jotVxH4WFI`twU!x`Lq`89Ip(=p>?0 z7GV|hj~@hr+dsG*z&e1IBqfTHz=sCT`7 zoIWyqe333#94}30W{k?V7bujz&G(}9La#YUE?H$>3VofgC(NTyU{t!1*07Y^SzQ|J zo^Op!8X779($f(AQ>>PgJ&ocvo(>1)*{L5~r#K>6`A5kJk(o@7-gFxDnfu!IcPK(SpNQ?x20vb`` z93$7Dk%Bh_O4yVLF`U^r;m6Pz)zIiy#B?w_mr6|rL%^+~9B)&=SGJcza?D3Yg&s&4 z{kMpAyn*h@3WM#hN(1ztp<(cAFLuWZCHOY&f`Lsza{`gO&lWrT2?aD-jeub!@kY?6 zOHJ(AAmGF;0`8i7I$+~&rG?M|OxThO9>EQve|?)+Y;TaRkgKG|n;SOgT2_NK(R^gm z|JVz4Q`ErxP(MzxsbMf>8esbb56(pnsWCB_5q*`>XA81dXq=(4NO%Xt&u=kRwAAr+ zR@9IwwY|iXt<^-`G+ah>%24loqhqXMs829O9+6vu2LP$qgTd;#M|s^ zh{1`_vkcGa1P;VP=+u9VF6sZwptTx>1O5ck0vp>d*pg^-jp(h+Zv^t9B+(h1VWij7`v!2jG(*`_cb{;PUAT@rK9v#4o)Hg z5$%3#OAsW{b_3&2hp=EdQC2T(OOnv@!x=2>NSyRovVvbnNFe;aeYwX#ir2kF~acgUUC zoFI0OCU=r&*_R^M!IQ9m!e0CRL+kFP0~e`#J9v^Qzf z=;z(}ntaZwu*s)}gxn@VharLukA4atMD>ph8}nLYzxs|Kb`%8fxc>7FXa4jD^?%^0 z{(A_;Icsg=b)E_3tyV`9O};56W5Jc?Sk= z#_N&N8~IQ+r6HqShxc>&(g6D62%C-%H;4tid|hCP!{Hd!@9O@mfy%rkSQk!U(bu*F@BLSpjhAoe4sWiGLiA%lsmsp$Io+u zwxBwI{>bE01je`C3rK1{1W9X4WkF0C7?u#h8aWv*=Gz!X7CR16;2PdFvnRF^iP>u7 zWk>&eP7(plkTu_6jJ4Mj+L30c^bk!&9DKB{tAx9=sBjX=I)ym+U*HDV2gE!D@N}nVb}e|&frBX*tO2m? z0pv&$pVhx2=_#bqaME`cZ7>-WYD~%c80?1R>_g5f-i9GHbJeFMwI8W(iSgtB03s7u z+`;WMB|Jk+gWEoE$@RmMpVn5tE^b&xRyW}3D9iGBZ+?yEpGMKPzzK_BKkTs6En z!+XE9ACq)g=Lt;yJv;-X_|NL{jBxU=kPc&fKm9pLr(ND3A$>GVKOyNGg(9G-@TTy| zZh5j8#$u|ETtNsIdvR$5VFW;iX2JyC4{|$MvL!SIA*|G0| zQ;-CeR2VhJWXHZSg>lKR-Ye9`dBb>vjZduZl$3F>LfU+}4R;TS zD|a}kj7KWt%W#KnMT+C-(Lt_$h*Z+WL(A#a2YHA052Ms4@$@0aSC9BBAHH?~HxJ*A zgG!53?ztUza9w?b@4(^wKa>%96fKJB!yJ(cWxe;q=64%%J;u0*ZjPC@5es0Ye-Xlt z55VzejB>V>3>b4c&oz)LAH{v*Av3g<|W-j;``975WxG6=^2qU|kt(;W- z*HUQpe?t5edUG0OkD}}|Qg*f-5?gPto|WEXOH!!&l}zAnv;Jq&mviU~`-&E<^huAn z`JqQB!SBUNPSWL0(hn^6aolfwQ))pRC7AXizWM}Yi`k`u-`*s8{dM&5Nwyh%?61>0 z`T`DHtv5Mq0?I&+5buW z#5I<9!_Ua)C467_vK_-v`xD3l6#d({7~-K5MT@if49K6r5B{MJg2_j=fl|F?JJIri z-`sTtN?rN>Y?tO2nk`b6+^xpqO<2~`mndzp z!HF*+N#0`He~JO=WJ83?UdKH||1tNg2N?^uD*gX6jhmAmk~bI=T6z<2zt7tZjD3}{ zos4~rvF(igGGk=x4R?7DV?SV8UeXwR9RWNoU~r(o(|?~2-(et3=zo+L1KE`Sgt6~2 z7~`v7Wgxq-(B}96wlT>IX>70GPb$tra|~~LkR8ewk9_TfXrIlF-rC3ikmU=-PlcLs zqSe~R_b^RZ8<9%>0&l;<;3)*szQ6BZ?Lx4 zm?vY7Mlls%C8;qh$$&yO02oM+=L)@ma2%7ux712=0*~Q0eE4}i<5--nFe;N@XX)SL z?cZS)ajJr3#0rCV9R2ZP8s4KiA=>Nj0@`0Cx$!F~5WZ6=qX&6Obt72^DpCS6!`#6# z#!scQr0ySCiVS>y=0L0(#w2HLV?K~B12Ibe={yNcNF3*>kWT;P%m+e74hfK@p~#fa z=(T(R1Q5>_GAGUwff(Ykm28q4a`R>yj84wan~T$RS(odIuFi()9(1~OI{rLvfDW;R z@yrjst3vGqO#*%pKHQ=J2)+{ae)2FyZp-3H2uK}*Apg5GQU7!n&1HMdy8Je0>QNMJ znAo2{jDC6rpylg#SRDU_c?jzIBaF>3_M->_F^wz*>e`H^doyF5^Qr$4(q2qr4t+~K zj3t;m%u<)Q^iEA)NELz(Y?<>M#m&+dnvilhaLR`CISd&&F#HS=_7PBlavtZf*-1!! z`=7lMA10@RUYMQu11tw+foMk zL;cmSabozdv5$Z2b_ndz)QakXaq|J_)Fdbxor-dZvt6{iGilaIe&d8 z74gsk$+zfX1KBI@T?Rp!0%;VhzzSnIjeg;|ogt%W0lj6$NDy z)=WY&Kp~kBRV7rKU;_#eY89Xtu@|T#%H#W=)39#xKRy8klA=olUvWhJJHINE zuY%7<_EJ5QZ~MphMe>LPE#|Wt=JU(^HU?}-#(k&aIKOqH7!+kRqvQDtZbwnZa@LVA zU`S1_(eu--p6L4hPEm+Z%B#{+9yVL$D!ZWVJU&a=QCiqsw4}RgdhM&rm+omijhDGi zh&hvp0kzTCh)Aq3S&qIQyX%N9-Huhq9@y3U5dYF%5g{zD!jK4|z1Rqfk3uujA zi&JknAs?wXq!$dy+z0VH+=b8J1Rai@DCn`(C%DX5UL@M}Rhqeyo(4%oi!*))$Dhpb z6GW{djH+BTuV_ZCH0WT#nv2Iv2@^1TZ#$O;fh^7`9K5ERA4W<@PH2HEjE`ZFI^L)-8GlU6p?w z+vp!6iD=b7r-2xpV?vq$yCU$tl?3Gdmw8lyrekXW3Na8(@)IAFGHis^5v2+|rf+ia zzW({P5_(a>KXkMD55SYfZtQ^cyHAka5K$rA_PPvLVn9~lSMb4+Klov_q2}1N9?6GV zjqU*J<%hJdxEo!8Ai@HyPL+yh*=m60_l98Mt)xA*>jhlI9EJ#@$xw%%N{bC zaDEpPMq{pxW$OiL4#>(Gd`FijLUVq;#X3HZItQ~x|K8jsq*#3%RS1C{5Dlafycini zC7?gTNv;=}qjQVZ8v%l=gpdPxXw1V&aPg0yj3HS@BWV6H4)bWhP7Yz0%(%TpV^LV( zP2l8n@ZUOr9?aD;+QaL|*LwT{6Bn@bMoP+G+i5X82VG zzs6iy$bR9N#2gbepZWU@KK$1V05axWk~kojj{xXFrity7D8zn&`C?2Hr?@*9+rvO4 zg7gb&lokgVqjy1Llq#a$Q@nkG!BY&LX7CJyXBkW|m|`GC{PTlAb zcc($j$J6Ft63+(GeVGI4htsi4Px_v8Jd?zuWCnl8#lPY7AfE0A(KLwPJ^1ZS-;4A_ zI+O0l@4j>rzb)ze)7uam#m~H#OCL{f)l?14J*nZQS2y)JX zQo|G8E#t5`j}XI39ZEUuYOx9Zf1yT$1$xZ-zUS(>b7!7BHH9TlXE4Q!m!>XUoG4D7 zn<}2WeEH&Ke#v#AT6WFa0~=@hPTz(}gxxkz3D42Bs;#f{ zRn4Hvw|c?vE$X_Q+R}9)+evi%|U1%)XAT2+o^kroTnRKw&^60iKZPfIK01 ziVS^eMu!r57htD@nZg@p_@@w&PkR!p!1{2D#JsZODn8D1IaP8{oPUw4d6 zbu-hy%|XtgD-kfZH0h?3k({OP1#5i(}vTDS$`TSykSSAkrfD1*w| zY+}YvYa*}^lr7>k-fs0?FeNy$E+`RL>gGW=v1DK({DMmY9K9v!VM96pG5n;V zKZc0U+*oKN8_Z9ZfPXZ{v*2wwRurb1UbGp1Op$ zDlldD{zV?v$QTW~#Nw2Ey7-7GASW>~_mf}dzh8R1UEY{AKKQ3gzUWtS~PQaOh5=J^LRUe z6xc^bAzX$|mi#3l#_*Q>iWY4D2sJR~UbFChHj|Ts9>buM^V{oUkwP!K7t1h#1q#y( z4lE|nDyGdd3~xve5^MF$-clU7o4$_9e zG6&Mveg1+HC4XSPxt`Qj|67zCPC8JAO!C{T;xz_B_{)AO6F-J_o7w*wp8C_M_cK-I z?0&{Xo^yb)uJ!R9)YZpUj^JgPG>eM6G-OxAmq|-AM>tVwWC|zh2%dCL6sO1$gHGt* zu&lAbaddQJ#OUKz6O{40*ot5=!<^^CID}CuI>M=etv*6%uU30~s}tDE1P3yh=UQBU z5>r3WD(l8s_`tyQR`A6IF!@J(Q_#48!B?^NXcjt*&G6^1e-Ur`9_N?_{jBKAu743L z(yENYx@A<_J$enFX+zwqY;<8m1M2+;DuDx5_}m&l#HxC5C?unL5oYCR2*7J~3pQfW zYDo!ORUi7zQpWLYTdTEWxd~B1oEIg;JKX$*)e{fctz#kL{`$_dA>{$!-LdU$Q1Qzw z;0*>V41_XlfdCn3tWLMh2JF;TGE4utwrn)Jesr>hok4{ z1*Gkp(t*LKk<1JICI%&+W$|BNa4&;L0U~mY#4xTT-C)5jcsqp9LN*X0%kigZ*%+5R zz*ol^bb;)BNV}IHOQZ&n#j!_Xl>y-a5reRn0$_aH3*bA7kXH5l4InLat4*E&`xVLx z5dar|NQs@qjSEH(Od$n{5GT^ypb~?_6{ta77|m^@0y?%4@+`1_@bZRq00E7kD33By zrHMkuU-t09(@c*WQe_-5$cTWQou>bM9M5IPo5J|Kg1U`7RhGWZTTUnxN``&{@1;M$ z)I0|?CL88}Hd5LtyUjrG z>H^Y8oQgmOw2@;zTS83dWlM0FRGtya2QJe2j?E%SG-xyc@)rgq<28p<_cEYBTlX=b zEska*!Pb@=x}Rw@WiX#i5hd0zZ;617>Pk#ES~@LJ(`@0>tqis?*v?=F15sk-8M~Lk zP6oRe>}Ifs!CnUUG1$jIlvVo~yPpAhX!$~{9%t+zgF_5N@phQ8iwur1AmZ?nhHq~3 z+$5R;VE`zB*N-tc#z26yMWk@WimOL2w&>34leqN~i;zJ&`V=3XWq#c{Ua0Y;x7gBngXaNA6Akw~Ppg`w9-c2X(bR>D z5;4lW3wT>UBb7C#D@22`3U>i;gsK&aQK%N#5e0L|dJ$-A!%+gKE(=N@hufjWs(oS* zcTNQXKOWfngR&^PuQzMGd0!U38%Cnra8Icgag@k1a7`mdmwgr>dn7UNF?{XgW0Cd# T3fXPhzHEPXQ+6bKB%S&1nG-67 literal 0 HcmV?d00001 diff --git a/.weechat/python/matrix/__pycache__/uploads.cpython-37.pyc b/.weechat/python/matrix/__pycache__/uploads.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..698a71cfd836fefdc6457a20ba4cc71224361214 GIT binary patch literal 8981 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 EAIh#dUH||9 literal 0 HcmV?d00001 diff --git a/.weechat/python/matrix/__pycache__/utf.cpython-37.pyc b/.weechat/python/matrix/__pycache__/utf.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f6cc24642620faa1ca94cf3c507ba93cd3851ca4 GIT binary patch literal 2616 zcmd5-&2Jk;6rb4-d+j)h8$u~n3k#|ML+VIy>7k0M7OCPuE(Ia21j~AN9IvzMH8W$< zSnkP{niEIP$&o96&Ptp(^+5ZXviHZs?|<(y z_9y+E7Y~C+sOncV8OwOVN*-}eW2bN;2fbUkr5AanANgk9D;i}pYH}u=aWHOW{-Gly zGj3z-%LscK#ah{kI^}w_&e^1EW;QNop0cc&1ut0^h|Q%Nb+cC1e#xR6*;>{?zm=_L z8|dH4oM)`J`5Lyd9KhcnG|Zj=hXh-SPNCVN=u0NM>3hJ*Dh^v>fkGq z8x*2$ew|FG`Q#uaRp-UQ;1R0YK_ghiGX{rw=HO^Ib2AUUm-$%(y>AVU8d(b_wd;-V zgh)q;{to9&g-l5o73!%zK~;qNf-hJMII)ZG!@_m4a4NDmEfS?*q^CqNlqC8alO1$_ zeY!iUO0hexRGy4>`$zVq-RZF&Rg>K^(K3I&JJZAd^tcY0xS}xNO>PseS+qMX|`ZXj+QU^FPyfV@G(au zgeUwN)A(PaCzsuNLrl!I=_-cR4~(yMHxp)ZD67&;L(og)fvOvaN4A$j%?b^O8l`~i znOwus1|qqRQQa6-)uG6~Agcm2jBhz@?(zA~HUA0ncvwkuPXA$1r3jwAzTIvV_FSuc z8(QSs)X+`z9BC%Zx#mc7dl#xchm0a%7~g2l@BG)P|A77p?fnSNf{ocJU+^=aXmOM8 zE!e5MaLx!x?-EIWgK|a$KKr2#`W9YE?2nh-qly zBj+rJYZuDmcn`BHAUizZZ9cz!&7~{CsSjx?)KfJM-NRtvoq|X*=gfKG!4bs&*crQL zaB<-*+=aLFOmx+}J$Kt-tLN2CDW*k|uFR)Ys5ho^qT?)qe>yo(J>R<17|e!4A#0CT zB9E5pW+{|P4#YLrl}te|lxj(?gZ zgrKF3+d>;kQbJdXz)F zl1A5XHc53}O;kt`Br>a~nrW41BCLj1TBS0d>YY&Z5Bf%MkfevJGmk7e^^Lj|_M39< zl_3lYa?Qr@2am({2?Blr%6LX3;usQfEQ!EJXFrFq3o3Bcv8vl6@%(2Y6)dzt?Z_Kg zm&910(%{^D3%*IvrlnW;4Mgn4du`r!=68sBV#_&~46qXJaY+%3bSGQo$JCho=XSzq zUb~`UpR}9AZ)~?DG2+FA(#2k+!vH_Tl$tL=B}fKPtjTJcb20bf0uO_c@bLFo`v#DP zZbCLVV@t!LX1#^=AFi(3HQEo@=02>u{{RnDJ6HKc>12X*qM)i9)@e!RN}|cy)5lX$ zyJa#psMbE7(^APM)_^LdR&phr$$2C9BL?QNV4w#2Jk*=0TimSwUm%b@vY6bh8}{Wf zKp5ZUXSB9M%_cRJoh!#By`b0qCZPiL-~6Wa-ZoLiaHeMxR3Rz$k7+%E4pxA1~?Tip+Wt)TNaH)dNm literal 0 HcmV?d00001 diff --git a/.weechat/python/matrix/__pycache__/utils.cpython-37.pyc b/.weechat/python/matrix/__pycache__/utils.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..40a854839fea9b08a09f2609bc6e6e7cc962cb3c GIT binary patch literal 4678 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 Q?xIWclIyrt`g_;@4GI!IvH$=8 literal 0 HcmV?d00001 diff --git a/.weechat/python/matrix/_weechat.py b/.weechat/python/matrix/_weechat.py new file mode 100644 index 0000000..d8b8356 --- /dev/null +++ b/.weechat/python/matrix/_weechat.py @@ -0,0 +1,248 @@ +import datetime +import random +import string + +WEECHAT_BASE_COLORS = { + "black": "0", + "red": "1", + "green": "2", + "brown": "3", + "blue": "4", + "magenta": "5", + "cyan": "6", + "default": "7", + "gray": "8", + "lightred": "9", + "lightgreen": "10", + "yellow": "11", + "lightblue": "12", + "lightmagenta": "13", + "lightcyan": "14", + "white": "15" +} + + +class MockObject(object): + pass + +class MockConfig(object): + config_template = { + 'debug_buffer': None, + 'debug_category': None, + '_ptr': None, + 'read': None, + 'free': None, + 'page_up_hook': None, + 'color': { + 'error_message_bg': "", + 'error_message_fg': "", + 'quote_bg': "", + 'quote_fg': "", + 'unconfirmed_message_bg': "", + 'unconfirmed_message_fg': "", + 'untagged_code_bg': "", + 'untagged_code_fg': "", + }, + 'upload_buffer': { + 'display': None, + 'move_line_down': None, + 'move_line_up': None, + 'render': None, + }, + 'look': { + 'bar_item_typing_notice_prefix': None, + 'busy_sign': None, + 'code_block_margin': None, + 'code_blocks': None, + 'disconnect_sign': None, + 'encrypted_room_sign': None, + 'encryption_warning_sign': None, + 'max_typing_notice_item_length': None, + 'pygments_style': None, + 'redactions': None, + 'server_buffer': None, + }, + 'network': { + 'debug_buffer': None, + 'debug_category': None, + 'debug_level': None, + 'fetch_backlog_on_pgup': None, + 'lag_min_show': None, + 'lag_reconnect': None, + 'lazy_load_room_users': None, + 'max_initial_sync_events': None, + 'max_nicklist_users': None, + 'print_unconfirmed_messages': None, + 'read_markers_conditions': None, + 'typing_notice_conditions': None, + }, + } + + def __init__(self): + for category, options in MockConfig.config_template.items(): + if options: + category_object = MockObject() + for option, value in options.items(): + setattr(category_object, option, value) + else: + category_object = options + + setattr(self, category, category_object) + + +def color(color_name): + # type: (str) -> str + # yapf: disable + escape_codes = [] + reset_code = "0" + + def make_fg_color(color_code): + return "38;5;{}".format(color_code) + + def make_bg_color(color_code): + return "48;5;{}".format(color_code) + + attributes = { + "bold": "1", + "-bold": "21", + "reverse": "27", + "-reverse": "21", + "italic": "3", + "-italic": "23", + "underline": "4", + "-underline": "24", + "reset": "0", + "resetcolor": "39" + } + + short_attributes = { + "*": "1", + "!": "27", + "/": "3", + "_": "4" + } + + colors = color_name.split(",", 2) + + fg_color = colors.pop(0) + + bg_color = colors.pop(0) if colors else "" + + if fg_color in attributes: + escape_codes.append(attributes[fg_color]) + else: + chars = list(fg_color) + + for char in chars: + if char in short_attributes: + escape_codes.append(short_attributes[char]) + elif char == "|": + reset_code = "" + else: + break + + stripped_color = fg_color.lstrip("*_|/!") + + if stripped_color in WEECHAT_BASE_COLORS: + escape_codes.append( + make_fg_color(WEECHAT_BASE_COLORS[stripped_color])) + + elif stripped_color.isdigit(): + num_color = int(stripped_color) + if 0 <= num_color < 256: + escape_codes.append(make_fg_color(stripped_color)) + + if bg_color in WEECHAT_BASE_COLORS: + escape_codes.append(make_bg_color(WEECHAT_BASE_COLORS[bg_color])) + else: + if bg_color.isdigit(): + num_color = int(bg_color) + if 0 <= num_color < 256: + escape_codes.append(make_bg_color(bg_color)) + + escape_string = "\033[{}{}m".format(reset_code, ";".join(escape_codes)) + + return escape_string + + +def prefix(prefix_string): + prefix_to_symbol = { + "error": "=!=", + "network": "--", + "action": "*", + "join": "-->", + "quit": "<--" + } + + if prefix_string in prefix_to_symbol: + return prefix_to_symbol[prefix] + + return "" + + +def prnt(_, message): + print(message) + + +def prnt_date_tags(_, date, tags_string, data): + message = "{} {} [{}]".format( + datetime.datetime.fromtimestamp(date), + data, + tags_string + ) + print(message) + + +def config_search_section(*_, **__): + pass + + +def config_new_option(*_, **__): + pass + + +def mkdir_home(*_, **__): + return True + + +def info_get(info, *_): + if info == "nick_color_name": + return random.choice(list(WEECHAT_BASE_COLORS.keys())) + + return "" + + +def buffer_new(*_, **__): + return "".join( + random.choice(string.ascii_uppercase + string.digits) for _ in range(8) + ) + + +def buffer_set(*_, **__): + return + + +def buffer_get_string(_ptr, property): + if property == "localvar_type": + return "channel" + return "" + + +def nicklist_add_group(*_, **__): + return + + +def nicklist_add_nick(*_, **__): + return + + +def nicklist_remove_nick(*_, **__): + return + + +def nicklist_search_nick(*args, **kwargs): + return buffer_new(args, kwargs) + + +def string_remove_color(message, _): + return message diff --git a/.weechat/python/matrix/bar_items.py b/.weechat/python/matrix/bar_items.py new file mode 100644 index 0000000..96e83a6 --- /dev/null +++ b/.weechat/python/matrix/bar_items.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2018, 2019 Damir Jelić +# +# Permission to use, copy, modify, and/or distribute this software for +# any purpose with or without fee is hereby granted, provided that the +# above copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER +# RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from __future__ import unicode_literals + +from . import globals as G +from .globals import SERVERS, W +from .utf import utf8_decode + + +@utf8_decode +def matrix_bar_item_plugin(data, item, window, buffer, extra_info): + # pylint: disable=unused-argument + for server in SERVERS.values(): + if buffer in server.buffers.values() or buffer == server.server_buffer: + return "matrix{color}/{color_fg}{name}".format( + color=W.color("bar_delim"), + color_fg=W.color("bar_fg"), + name=server.name, + ) + + ptr_plugin = W.buffer_get_pointer(buffer, "plugin") + name = W.plugin_get_name(ptr_plugin) + + return name + + +@utf8_decode +def matrix_bar_item_name(data, item, window, buffer, extra_info): + # pylint: disable=unused-argument + for server in SERVERS.values(): + if buffer in server.buffers.values(): + color = ( + "status_name_ssl" + if server.ssl_context.check_hostname + else "status_name" + ) + + room_buffer = server.find_room_from_ptr(buffer) + room = room_buffer.room + + return "{color}{name}".format( + color=W.color(color), name=room.display_name + ) + + if buffer == server.server_buffer: + color = ( + "status_name_ssl" + if server.ssl_context.check_hostname + else "status_name" + ) + + return "{color}server{del_color}[{color}{name}{del_color}]".format( + color=W.color(color), + del_color=W.color("bar_delim"), + name=server.name, + ) + + name = W.buffer_get_string(buffer, "name") + + return "{}{}".format(W.color("status_name"), name) + + +@utf8_decode +def matrix_bar_item_lag(data, item, window, buffer, extra_info): + # pylint: disable=unused-argument + for server in SERVERS.values(): + if buffer in server.buffers.values() or buffer == server.server_buffer: + if server.lag >= G.CONFIG.network.lag_min_show: + color = W.color("irc.color.item_lag_counting") + if server.lag_done: + color = W.color("irc.color.item_lag_finished") + + lag = "{0:.3f}" if round(server.lag) < 1000 else "{0:.0f}" + lag_string = "Lag: {color}{lag}{ncolor}".format( + lag=lag.format((server.lag / 1000)), + color=color, + ncolor=W.color("reset"), + ) + return lag_string + return "" + + return "" + + +@utf8_decode +def matrix_bar_item_buffer_modes(data, item, window, buffer, extra_info): + # pylint: disable=unused-argument + for server in SERVERS.values(): + if buffer in server.buffers.values(): + room_buffer = server.find_room_from_ptr(buffer) + room = room_buffer.room + modes = [] + + if room.encrypted: + modes.append(G.CONFIG.look.encrypted_room_sign) + + if (server.client + and server.client.room_contains_unverified(room.room_id)): + modes.append(G.CONFIG.look.encryption_warning_sign) + + if not server.connected: + modes.append(G.CONFIG.look.disconnect_sign) + + if room_buffer.backlog_pending or server.busy: + modes.append(G.CONFIG.look.busy_sign) + + return "".join(modes) + + return "" + + +@utf8_decode +def matrix_bar_nicklist_count(data, item, window, buffer, extra_info): + # pylint: disable=unused-argument + color = W.color("status_nicklist_count") + + for server in SERVERS.values(): + if buffer in server.buffers.values(): + room_buffer = server.find_room_from_ptr(buffer) + room = room_buffer.room + return "{}{}".format(color, room.member_count) + + nicklist_enabled = bool(W.buffer_get_integer(buffer, "nicklist")) + + if nicklist_enabled: + nick_count = W.buffer_get_integer(buffer, "nicklist_visible_count") + return "{}{}".format(color, nick_count) + + return "" + + +@utf8_decode +def matrix_bar_typing_notices_cb(data, item, window, buffer, extra_info): + """Update a status bar item showing users currently typing. + This function is called by weechat every time a buffer is switched or + W.bar_item_update() is explicitly called. The bar item shows + currently typing users for the current buffer.""" + # pylint: disable=unused-argument + for server in SERVERS.values(): + if buffer in server.buffers.values(): + room_buffer = server.find_room_from_ptr(buffer) + room = room_buffer.room + + if room.typing_users: + nicks = [] + + for user_id in room.typing_users: + if user_id == room.own_user_id: + continue + + nick = room_buffer.displayed_nicks.get(user_id, user_id) + nicks.append(nick) + + if not nicks: + return "" + + msg = "{}{}".format( + G.CONFIG.look.bar_item_typing_notice_prefix, + ", ".join(sorted(nicks)) + ) + + max_len = G.CONFIG.look.max_typing_notice_item_length + if len(msg) > max_len: + msg[:max_len - 3] + "..." + + return msg + + return "" + + return "" + + +def init_bar_items(): + W.bar_item_new("(extra)buffer_plugin", "matrix_bar_item_plugin", "") + W.bar_item_new("(extra)buffer_name", "matrix_bar_item_name", "") + W.bar_item_new("(extra)lag", "matrix_bar_item_lag", "") + W.bar_item_new( + "(extra)buffer_nicklist_count", + "matrix_bar_nicklist_count", + "" + ) + W.bar_item_new( + "(extra)matrix_typing_notice", + "matrix_bar_typing_notices_cb", + "" + ) + W.bar_item_new("(extra)buffer_modes", "matrix_bar_item_buffer_modes", "") + W.bar_item_new("(extra)matrix_modes", "matrix_bar_item_buffer_modes", "") diff --git a/.weechat/python/matrix/buffer.py b/.weechat/python/matrix/buffer.py new file mode 100644 index 0000000..3bb9acb --- /dev/null +++ b/.weechat/python/matrix/buffer.py @@ -0,0 +1,1763 @@ +# -*- coding: utf-8 -*- + +# Weechat Matrix Protocol Script +# Copyright © 2018, 2019 Damir Jelić +# +# Permission to use, copy, modify, and/or distribute this software for +# any purpose with or without fee is hereby granted, provided that the +# above copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER +# RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from __future__ import unicode_literals + +import time +import attr +import pprint +from builtins import super +from functools import partial +from collections import deque +from typing import Dict, List, NamedTuple, Optional, Set +from uuid import UUID + +from nio import ( + Api, + PowerLevelsEvent, + RedactedEvent, + RedactionEvent, + RoomAliasEvent, + RoomEncryptionEvent, + RoomMemberEvent, + RoomMessage, + RoomMessageEmote, + RoomMessageMedia, + RoomEncryptedMedia, + RoomMessageNotice, + RoomMessageText, + RoomMessageUnknown, + RoomNameEvent, + RoomTopicEvent, + MegolmEvent, + Event, + OlmTrustError, + UnknownEvent, + FullyReadEvent, + BadEvent, + UnknownBadEvent, +) + +from . import globals as G +from .colors import Formatted +from .config import RedactType +from .globals import SCRIPT_NAME, SERVERS, W, TYPING_NOTICE_TIMEOUT +from .utf import utf8_decode +from .utils import ( + server_ts_to_weechat, + shorten_sender, + string_strikethrough, + color_pair, +) + + +@attr.s +class OwnMessages(object): + sender = attr.ib(type=str) + age = attr.ib(type=int) + event_id = attr.ib(type=str) + uuid = attr.ib(type=str) + room_id = attr.ib(type=str) + formatted_message = attr.ib(type=Formatted) + + +class OwnMessage(OwnMessages): + pass + + +class OwnAction(OwnMessage): + pass + + +@utf8_decode +def room_buffer_input_cb(server_name, buffer, input_data): + server = SERVERS[server_name] + room_buffer = server.find_room_from_ptr(buffer) + + if not room_buffer: + # TODO log error + return W.WEECHAT_RC_ERROR + + if not server.connected: + room_buffer.error("You are not connected to the server") + return W.WEECHAT_RC_ERROR + + data = W.string_input_for_buffer(input_data) + + if not data: + data = input_data + + formatted_data = Formatted.from_input_line(data) + + try: + server.room_send_message(room_buffer, formatted_data, "m.text") + room_buffer.last_message = None + except OlmTrustError as e: + if (G.CONFIG.network.resending_ignores_devices + and room_buffer.last_message): + room_buffer.error("Ignoring unverified devices.") + + if (room_buffer.last_message.to_weechat() == + formatted_data.to_weechat()): + server.room_send_message(room_buffer, formatted_data, "m.text", + ignore_unverified_devices=True) + room_buffer.last_message = None + else: + # If the item is a normal user message store it in the + # buffer to enable the send-anyways functionality. + room_buffer.error("Untrusted devices found in room: {}".format(e)) + room_buffer.last_message = formatted_data + + return W.WEECHAT_RC_OK + + +@utf8_decode +def room_buffer_close_cb(server_name, buffer): + server = SERVERS[server_name] + room_buffer = server.find_room_from_ptr(buffer) + + if room_buffer: + room_id = room_buffer.room.room_id + server.buffers.pop(room_id, None) + server.room_buffers.pop(room_id, None) + + return W.WEECHAT_RC_OK + + +class WeechatUser(object): + def __init__(self, nick, host=None, prefix="", join_time=None): + # type: (str, str, str, int) -> None + self.nick = nick + self.host = host + self.prefix = prefix + self.color = W.info_get("nick_color_name", nick) + self.join_time = join_time or time.time() + self.speaking_time = None # type: Optional[int] + + def update_speaking_time(self, new_time=None): + self.speaking_time = new_time or time.time() + + @property + def joined_recently(self): + # TODO make the delay configurable + delay = 30 + limit = time.time() - (delay * 60) + return self.join_time < limit + + @property + def spoken_recently(self): + if not self.speaking_time: + return False + + # TODO make the delay configurable + delay = 5 + limit = time.time() - (delay * 60) + return self.speaking_time < limit + + +class RoomUser(WeechatUser): + def __init__(self, nick, user_id=None, power_level=0, join_time=None): + # type: (str, str, int, int) -> None + prefix = self._get_prefix(power_level) + super().__init__(nick, user_id, prefix, join_time) + + @property + def power_level(self): + # This shouldn't be used since it's a lossy function. It's only here + # for the setter + if self.prefix == "&": + return 100 + if self.prefix == "@": + return 50 + if self.prefix == "+": + return 10 + return 0 + + @power_level.setter + def power_level(self, level): + self.prefix = self._get_prefix(level) + + @staticmethod + def _get_prefix(power_level): + # type: (int) -> str + if power_level >= 100: + return "&" + if power_level >= 50: + return "@" + if power_level > 0: + return "+" + return "" + + +class WeechatChannelBuffer(object): + tags = { + "message": [SCRIPT_NAME + "_message", "notify_message", "log1"], + "message_private": [ + SCRIPT_NAME + "_message", + "notify_private", + "log1" + ], + "self_message": [ + SCRIPT_NAME + "_message", + "notify_none", + "no_highlight", + "self_msg", + "log1", + ], + "action": [ + SCRIPT_NAME + "_message", + SCRIPT_NAME + "_action", + "notify_message", + "log1", + ], + "action_private": [ + SCRIPT_NAME + "_message", + SCRIPT_NAME + "_action", + "notify_private", + "log1", + ], + "notice": [SCRIPT_NAME + "_notice", "notify_message", "log1"], + "old_message": [ + SCRIPT_NAME + "_message", + "notify_message", + "no_log", + "no_highlight", + ], + "join": [SCRIPT_NAME + "_join", "log4"], + "part": [SCRIPT_NAME + "_leave", "log4"], + "kick": [SCRIPT_NAME + "_kick", "log4"], + "invite": [SCRIPT_NAME + "_invite", "log4"], + "topic": [SCRIPT_NAME + "_topic", "log3"], + } + + membership_messages = { + "join": "has joined", + "part": "has left", + "kick": "has been kicked from", + "invite": "has been invited to", + } + + class Line(object): + def __init__(self, pointer): + self._ptr = pointer + + @property + def _hdata(self): + return W.hdata_get("line_data") + + @property + def prefix(self): + return W.hdata_string(self._hdata, self._ptr, "prefix") + + @prefix.setter + def prefix(self, new_prefix): + new_data = {"prefix": new_prefix} + W.hdata_update(self._hdata, self._ptr, new_data) + + @property + def message(self): + return W.hdata_string(self._hdata, self._ptr, "message") + + @message.setter + def message(self, new_message): + # type: (str) -> None + new_data = {"message": new_message} + W.hdata_update(self._hdata, self._ptr, new_data) + + @property + def tags(self): + tags_count = W.hdata_get_var_array_size( + self._hdata, self._ptr, "tags_array" + ) + + tags = [ + W.hdata_string(self._hdata, self._ptr, "%d|tags_array" % i) + for i in range(tags_count) + ] + return tags + + @tags.setter + def tags(self, new_tags): + # type: (List[str]) -> None + new_data = {"tags_array": ",".join(new_tags)} + W.hdata_update(self._hdata, self._ptr, new_data) + + @property + def date(self): + # type: () -> int + return W.hdata_time(self._hdata, self._ptr, "date") + + @date.setter + def date(self, new_date): + # type: (int) -> None + new_data = {"date": str(new_date)} + W.hdata_update(self._hdata, self._ptr, new_data) + + @property + def date_printed(self): + # type: () -> int + return W.hdata_time(self._hdata, self._ptr, "date_printed") + + @date_printed.setter + def date_printed(self, new_date): + # type: (int) -> None + new_data = {"date_printed": str(new_date)} + W.hdata_update(self._hdata, self._ptr, new_data) + + @property + def highlight(self): + # type: () -> bool + return bool(W.hdata_char(self._hdata, self._ptr, "highlight")) + + def update( + self, + date=None, + date_printed=None, + tags=None, + prefix=None, + message=None, + highlight=None, + ): + new_data = {} + + if date is not None: + new_data["date"] = str(date) + if date_printed is not None: + new_data["date_printed"] = str(date_printed) + if tags is not None: + new_data["tags_array"] = ",".join(tags) + if prefix is not None: + new_data["prefix"] = prefix + if message is not None: + new_data["message"] = message + if highlight is not None: + new_data["highlight"] = highlight + + if new_data: + W.hdata_update(self._hdata, self._ptr, new_data) + + def __init__(self, name, server_name, user): + # type: (str, str, str) -> None + self._ptr = W.buffer_new( + name, + "room_buffer_input_cb", + server_name, + "room_buffer_close_cb", + server_name, + ) + + self.name = "" + self.users = {} # type: Dict[str, WeechatUser] + self.smart_filtered_nicks = set() # type: Set[str] + + self.topic_author = "" + self.topic_date = None + + W.buffer_set(self._ptr, "localvar_set_type", "private") + W.buffer_set(self._ptr, "type", "formatted") + + W.buffer_set(self._ptr, "localvar_set_channel", name) + + W.buffer_set(self._ptr, "localvar_set_nick", user) + + W.buffer_set(self._ptr, "localvar_set_server", server_name) + + W.nicklist_add_group( + self._ptr, "", "000|o", "weechat.color.nicklist_group", 1 + ) + W.nicklist_add_group( + self._ptr, "", "001|h", "weechat.color.nicklist_group", 1 + ) + W.nicklist_add_group( + self._ptr, "", "002|v", "weechat.color.nicklist_group", 1 + ) + W.nicklist_add_group( + self._ptr, "", "999|...", "weechat.color.nicklist_group", 1 + ) + + W.buffer_set(self._ptr, "nicklist", "1") + W.buffer_set(self._ptr, "nicklist_display_groups", "0") + + W.buffer_set(self._ptr, "highlight_words", user) + + # TODO make this configurable + W.buffer_set( + self._ptr, "highlight_tags_restrict", SCRIPT_NAME + "_message" + ) + + @property + def _hdata(self): + return W.hdata_get("buffer") + + def add_smart_filtered_nick(self, nick): + self.smart_filtered_nicks.add(nick) + + def remove_smart_filtered_nick(self, nick): + self.smart_filtered_nicks.discard(nick) + + def unmask_smart_filtered_nick(self, nick): + if nick not in self.smart_filtered_nicks: + return + + for line in self.lines: + filtered = False + join = False + tags = line.tags + + if "nick_{}".format(nick) not in tags: + continue + + if SCRIPT_NAME + "_smart_filter" in tags: + filtered = True + elif SCRIPT_NAME + "_join" in tags: + join = True + + if filtered: + tags.remove(SCRIPT_NAME + "_smart_filter") + line.tags = tags + + if join: + break + + self.remove_smart_filtered_nick(nick) + + @property + def input(self): + # type: () -> str + """Get the bar item input text of the buffer.""" + return W.buffer_get_string(self._ptr, "input") + + @property + def lines(self): + own_lines = W.hdata_pointer(self._hdata, self._ptr, "own_lines") + + if own_lines: + hdata_line = W.hdata_get("line") + + line_pointer = W.hdata_pointer( + W.hdata_get("lines"), own_lines, "last_line" + ) + + while line_pointer: + data_pointer = W.hdata_pointer( + hdata_line, line_pointer, "data" + ) + + if data_pointer: + yield WeechatChannelBuffer.Line(data_pointer) + + line_pointer = W.hdata_move(hdata_line, line_pointer, -1) + + def _print(self, string): + # type: (str) -> None + """ Print a string to the room buffer """ + W.prnt(self._ptr, string) + + def print_date_tags(self, data, date=None, tags=None): + # type: (str, Optional[int], Optional[List[str]]) -> None + date = date or int(time.time()) + tags = tags or [] + + tags_string = ",".join(tags) + W.prnt_date_tags(self._ptr, date, tags_string, data) + + def error(self, string): + # type: (str) -> None + """ Print an error to the room buffer """ + message = "{prefix}{script}: {message}".format( + prefix=W.prefix("error"), script=SCRIPT_NAME, message=string + ) + + self._print(message) + + @staticmethod + def _color_for_tags(color): + # type: (str) -> str + if color == "weechat.color.chat_nick_self": + option = W.config_get(color) + return W.config_string(option) + + return color + + def _message_tags(self, user, message_type): + # type: (WeechatUser, str) -> List[str] + tags = list(self.tags[message_type]) + + tags.append("nick_{nick}".format(nick=user.nick)) + + color = self._color_for_tags(user.color) + + if message_type not in ("action", "notice"): + tags.append("prefix_nick_{color}".format(color=color)) + + return tags + + def _get_user(self, nick): + # type: (str) -> WeechatUser + if nick in self.users: + return self.users[nick] + + # A message from a non joined user + return WeechatUser(nick) + + def _print_message(self, user, message, date, tags, extra_prefix=""): + prefix_string = ( + extra_prefix + if not user.prefix + else "{}{}{}{}".format( + extra_prefix, + W.color(self._get_prefix_color(user.prefix)), + user.prefix, + W.color("reset"), + ) + ) + + data = "{prefix}{color}{author}{ncolor}\t{msg}".format( + prefix=prefix_string, + color=W.color(user.color), + author=user.nick, + ncolor=W.color("reset"), + msg=message, + ) + + self.print_date_tags(data, date, tags) + + def message(self, nick, message, date, extra_tags=None, extra_prefix=""): + # type: (str, str, int, List[str], str) -> None + user = self._get_user(nick) + tags_type = "message_private" if self.type == "private" else "message" + tags = self._message_tags(user, tags_type) + (extra_tags or []) + self._print_message(user, message, date, tags, extra_prefix) + + user.update_speaking_time(date) + self.unmask_smart_filtered_nick(nick) + + def notice(self, nick, message, date, extra_tags=None, extra_prefix=""): + # type: (str, str, int, Optional[List[str]], str) -> None + user = self._get_user(nick) + user_prefix = ( + "" + if not user.prefix + else "{}{}{}".format( + W.color(self._get_prefix_color(user.prefix)), + user.prefix, + W.color("reset"), + ) + ) + + user_string = "{}{}{}{}".format( + user_prefix, W.color(user.color), user.nick, W.color("reset") + ) + + data = ( + "{extra_prefix}{prefix}{color}Notice" + "{del_color}({ncolor}{user}{del_color}){ncolor}" + ": {message}" + ).format( + extra_prefix=extra_prefix, + prefix=W.prefix("network"), + color=W.color("irc.color.notice"), + del_color=W.color("chat_delimiters"), + ncolor=W.color("reset"), + user=user_string, + message=message, + ) + + tags = self._message_tags(user, "notice") + (extra_tags or []) + self.print_date_tags(data, date, tags) + + user.update_speaking_time(date) + self.unmask_smart_filtered_nick(nick) + + def _format_action(self, user, message): + nick_prefix = ( + "" + if not user.prefix + else "{}{}{}".format( + W.color(self._get_prefix_color(user.prefix)), + user.prefix, + W.color("reset"), + ) + ) + + data = ( + "{nick_prefix}{nick_color}{author}" + "{ncolor} {msg}").format( + nick_prefix=nick_prefix, + nick_color=W.color(user.color), + author=user.nick, + ncolor=W.color("reset"), + msg=message, + ) + return data + + def _print_action(self, user, message, date, tags, extra_prefix=""): + data = self._format_action(user, message) + data = "{extra_prefix}{prefix}{data}".format( + extra_prefix=extra_prefix, + prefix=W.prefix("action"), + data=data) + + self.print_date_tags(data, date, tags) + + def action(self, nick, message, date, extra_tags=None, extra_prefix=""): + # type: (str, str, int, Optional[List[str]], str) -> None + user = self._get_user(nick) + tags_type = "action_private" if self.type == "private" else "action" + tags = self._message_tags(user, tags_type) + (extra_tags or []) + self._print_action(user, message, date, tags, extra_prefix) + + user.update_speaking_time(date) + self.unmask_smart_filtered_nick(nick) + + @staticmethod + def _get_nicklist_group(user): + # type: (WeechatUser) -> str + group_name = "999|..." + + if user.prefix == "&": + group_name = "000|o" + elif user.prefix == "@": + group_name = "001|h" + elif user.prefix > "+": + group_name = "002|v" + + return group_name + + @staticmethod + def _get_prefix_color(prefix): + # type: (str) -> str + # TODO make this configurable + color = "" + + if prefix == "&": + color = "lightgreen" + elif prefix == "@": + color = "lightgreen" + elif prefix == "+": + color = "yellow" + + return color + + def _add_user_to_nicklist(self, user): + # type: (WeechatUser) -> None + nick_pointer = W.nicklist_search_nick(self._ptr, "", user.nick) + + if not nick_pointer: + group = W.nicklist_search_group( + self._ptr, "", self._get_nicklist_group(user) + ) + prefix = user.prefix if user.prefix else " " + W.nicklist_add_nick( + self._ptr, + group, + user.nick, + user.color, + prefix, + self._get_prefix_color(user.prefix), + 1, + ) + + def _membership_message(self, user, message_type): + # type: (WeechatUser, str) -> str + action_color = "green" if message_type in ("join", "invite") else "red" + prefix = "join" if message_type in ("join", "invite") else "quit" + + membership_message = self.membership_messages[message_type] + + message = ( + "{prefix}{color}{author}{ncolor} " + "{del_color}({host_color}{host}{del_color})" + "{action_color} {message} " + "{channel_color}{room}{ncolor}" + ).format( + prefix=W.prefix(prefix), + color=W.color(user.color), + author=user.nick, + ncolor=W.color("reset"), + del_color=W.color("chat_delimiters"), + host_color=W.color("chat_host"), + host=user.host, + action_color=W.color(action_color), + message=membership_message, + channel_color=W.color("chat_channel"), + room=self.short_name, + ) + + return message + + def join(self, user, date, message=True, extra_tags=None): + # type: (WeechatUser, int, Optional[bool], Optional[List[str]]) -> None + self._add_user_to_nicklist(user) + self.users[user.nick] = user + + if len(self.users) > 2: + W.buffer_set(self._ptr, "localvar_set_type", "channel") + + if message: + tags = self._message_tags(user, "join") + msg = self._membership_message(user, "join") + + # TODO add a option to disable smart filters + tags.append(SCRIPT_NAME + "_smart_filter") + + self.print_date_tags(msg, date, tags) + self.add_smart_filtered_nick(user.nick) + + def invite(self, nick, date, extra_tags=None): + # type: (str, int, Optional[List[str]]) -> None + user = self._get_user(nick) + tags = self._message_tags(user, "invite") + message = self._membership_message(user, "invite") + self.print_date_tags(message, date, tags + (extra_tags or [])) + + def remove_user_from_nicklist(self, user): + # type: (WeechatUser) -> None + nick_pointer = W.nicklist_search_nick(self._ptr, "", user.nick) + + if nick_pointer: + W.nicklist_remove_nick(self._ptr, nick_pointer) + + def _leave(self, nick, date, message, leave_type, extra_tags=None): + # type: (str, int, bool, str, List[str]) -> None + user = self._get_user(nick) + self.remove_user_from_nicklist(user) + + if len(self.users) <= 2: + W.buffer_set(self._ptr, "localvar_set_type", "private") + + if message: + tags = self._message_tags(user, leave_type) + + # TODO make this configurable + if not user.spoken_recently: + tags.append(SCRIPT_NAME + "_smart_filter") + + msg = self._membership_message(user, leave_type) + self.print_date_tags(msg, date, tags + (extra_tags or [])) + self.remove_smart_filtered_nick(user.nick) + + if user.nick in self.users: + del self.users[user.nick] + + def part(self, nick, date, message=True, extra_tags=None): + # type: (str, int, bool, Optional[List[str]]) -> None + self._leave(nick, date, message, "part", extra_tags) + + def kick(self, nick, date, message=True, extra_tags=None): + # type: (str, int, bool, Optional[List[str]]) -> None + self._leave(nick, date, message, "kick", extra_tags) + + def _print_topic(self, nick, topic, date): + user = self._get_user(nick) + tags = self._message_tags(user, "topic") + + data = ( + "{prefix}{nick} has changed " + "the topic for {chan_color}{room}{ncolor} " + 'to "{topic}"' + ).format( + prefix=W.prefix("network"), + nick=user.nick, + chan_color=W.color("chat_channel"), + ncolor=W.color("reset"), + room=self.short_name, + topic=topic, + ) + + self.print_date_tags(data, date, tags) + user.update_speaking_time(date) + self.unmask_smart_filtered_nick(nick) + + @property + def topic(self): + return W.buffer_get_string(self._ptr, "title") + + @topic.setter + def topic(self, topic): + W.buffer_set(self._ptr, "title", topic) + + def change_topic(self, nick, topic, date, message=True): + if message: + self._print_topic(nick, topic, date) + + self.topic = topic + self.topic_author = nick + self.topic_date = date + + def self_message(self, nick, message, date, tags=None): + user = self._get_user(nick) + tags = self._message_tags(user, "self_message") + (tags or []) + self._print_message(user, message, date, tags) + + def self_action(self, nick, message, date, tags=None): + user = self._get_user(nick) + tags = self._message_tags(user, "self_message") + (tags or []) + tags.append(SCRIPT_NAME + "_action") + self._print_action(user, message, date, tags) + + @property + def type(self): + return W.buffer_get_string(self._ptr, "localvar_type") + + @property + def short_name(self): + return W.buffer_get_string(self._ptr, "short_name") + + @short_name.setter + def short_name(self, name): + W.buffer_set(self._ptr, "short_name", name) + + @property + def name(self): + return W.buffer_get_string(self._ptr, "name") + + @name.setter + def name(self, name): + W.buffer_set(self._ptr, "name", name) + + @property + def number(self): + """Get the buffer number, starts at 1.""" + return int(W.buffer_get_integer(self._ptr, "number")) + + def find_lines(self, predicate, max_lines=None): + lines = [] + count = 0 + for line in self.lines: + if predicate(line): + lines.append(line) + count += 1 + if max_lines is not None and count == max_lines: + return lines + + return lines + + +class RoomBuffer(object): + def __init__(self, room, server_name, homeserver, prev_batch): + self.room = room + self.homeserver = homeserver + self._backlog_pending = False + self.prev_batch = prev_batch + self.joined = True + self.leave_event_id = None # type: Optional[str] + self.members_fetched = False + self.unhandled_users = [] # type: List[str] + self.inactive_users = [] + + self.sent_messages_queue = dict() # type: Dict[UUID, OwnMessage] + self.printed_before_ack_queue = list() # type: List[UUID] + self.undecrypted_events = deque(maxlen=5000) + + self.typing_notice_time = None + self._typing = False + self.typing_enabled = True + + self.last_read_event = None + self._read_markers_enabled = True + self.server_name = server_name + + self.last_message = None + + buffer_name = "{}.{}".format(server_name, room.room_id) + + # This dict remembers the connection from a user_id to the name we + # displayed in the buffer + self.displayed_nicks = {} + user = shorten_sender(self.room.own_user_id) + + self.weechat_buffer = WeechatChannelBuffer( + buffer_name, server_name, user + ) + + W.buffer_set( + self.weechat_buffer._ptr, + "localvar_set_domain", + self.homeserver.hostname + ) + + W.buffer_set( + self.weechat_buffer._ptr, + "localvar_set_room_id", + room.room_id + ) + + @property + def backlog_pending(self): + return self._backlog_pending + + @backlog_pending.setter + def backlog_pending(self, value): + self._backlog_pending = value + W.bar_item_update("buffer_modes") + W.bar_item_update("matrix_modes") + + @property + def warning_prefix(self): + return "⚠️ " + + @property + def typing(self): + # type: () -> bool + """Return our typing status.""" + return self._typing + + @typing.setter + def typing(self, value): + self._typing = value + if value: + self.typing_notice_time = time.time() + else: + self.typing_notice_time = None + + @property + def typing_notice_expired(self): + # type: () -> bool + """Check if the typing notice has expired. + + Returns true if a new typing notice should be sent. + """ + if not self.typing_notice_time: + return True + + now = time.time() + if (now - self.typing_notice_time) > (TYPING_NOTICE_TIMEOUT / 1000): + return True + return False + + @property + def should_send_read_marker(self): + # type () -> bool + """Check if we need to send out a read receipt.""" + if not self.read_markers_enabled: + return False + + if not self.last_read_event: + return True + + if self.last_read_event == self.last_event_id: + return False + + return True + + @property + def last_event_id(self): + # type () -> str + """Get the event id of the last shown matrix event.""" + for line in self.weechat_buffer.lines: + for tag in line.tags: + if tag.startswith("matrix_id"): + event_id = tag[10:] + return event_id + + return "" + + @property + def read_markers_enabled(self): + # type: () -> bool + """Check if read receipts are enabled for this room.""" + return bool(int(W.string_eval_expression( + G.CONFIG.network.read_markers_conditions, + {}, + {"markers_enabled": str(int(self._read_markers_enabled))}, + {"type": "condition"} + ))) + + @read_markers_enabled.setter + def read_markers_enabled(self, value): + self._read_markers_enabled = value + + def find_nick(self, user_id): + # type: (str) -> str + """Find a suitable nick from a user_id.""" + if user_id in self.displayed_nicks: + return self.displayed_nicks[user_id] + + return user_id + + def add_user(self, user_id, date, is_state, force_add=False): + # User is already added don't add him again. + if user_id in self.displayed_nicks: + return + + try: + user = self.room.users[user_id] + except KeyError: + # No user found, he must have left already in an event that is + # yet to come, so do nothing + return + + # Adding users to the nicklist is a O(1) + search time + # operation (the nicks are added to a linked list sorted). + # The search time is O(N * min(a,b)) where N is the number + # of nicks already added and a/b are the length of + # the strings that are compared at every itteration. + # Because the search time get's increasingly longer we're + # going to stop adding inactive users, they will be lazily added if + # they become active. + if is_state and not force_add and user.power_level <= 0: + if (len(self.displayed_nicks) >= + G.CONFIG.network.max_nicklist_users): + self.inactive_users.append(user_id) + return + + try: + self.inactive_users.remove(user_id) + except ValueError: + pass + + short_name = shorten_sender(user.user_id) + + # TODO handle this special case for discord bridge users and + # freenode bridge users better + if (user.user_id.startswith("@_discord_") or + user.user_id.startswith("@_slack_") or + user.user_id.startswith("@whatsapp_") or + user.user_id.startswith("@facebook_") or + user.user_id.startswith("@telegram_") or + user.user_id.startswith("@_xmpp_")): + if user.display_name: + short_name = user.display_name[0:50] + elif user.user_id.startswith("@twilio_"): + short_name = shorten_sender(user.user_id[7:]) + elif user.user_id.startswith("@freenode_"): + short_name = shorten_sender(user.user_id[9:]) + elif user.user_id.startswith("@_ircnet_"): + short_name = shorten_sender(user.user_id[8:]) + elif user.user_id.startswith("@gitter_"): + short_name = shorten_sender(user.user_id[7:]) + + # TODO make this configurable + if not short_name or short_name in self.displayed_nicks.values(): + # Use the full user id, but don't include the @ + nick = user_id[1:] + else: + nick = short_name + + buffer_user = RoomUser(nick, user_id, user.power_level, date) + self.displayed_nicks[user_id] = nick + + if self.room.own_user_id == user_id: + buffer_user.color = "weechat.color.chat_nick_self" + user.nick_color = "weechat.color.chat_nick_self" + + self.weechat_buffer.join(buffer_user, date, not is_state) + + def handle_membership_events(self, event, is_state): + date = server_ts_to_weechat(event.server_timestamp) + + if event.content["membership"] == "join": + if (event.state_key not in self.displayed_nicks + and event.state_key not in self.inactive_users): + if len(self.room.users) > 100: + self.unhandled_users.append(event.state_key) + return + + self.add_user(event.state_key, date, is_state) + else: + # TODO print out profile changes + return + + elif event.content["membership"] == "leave": + if event.state_key in self.unhandled_users: + self.unhandled_users.remove(event.state_key) + return + + nick = self.find_nick(event.state_key) + if event.sender == event.state_key: + self.weechat_buffer.part(nick, date, not is_state) + else: + self.weechat_buffer.kick(nick, date, not is_state) + + if event.state_key in self.displayed_nicks: + del self.displayed_nicks[event.state_key] + + # We left the room, remember the event id of our leave, if we + # rejoin we get events that came before this event as well as + # after our leave, this way we know where to continue + if event.state_key == self.room.own_user_id: + self.leave_event_id = event.event_id + + elif event.content["membership"] == "invite": + if is_state: + return + + self.weechat_buffer.invite(event.state_key, date) + return + + self.update_buffer_name() + + def update_buffer_name(self): + room_name = self.room.display_name + self.weechat_buffer.short_name = room_name + + if G.CONFIG.human_buffer_names: + buffer_name = "{}.{}".format(self.server_name, room_name) + self.weechat_buffer.name = buffer_name + + def _redact_line(self, event): + def predicate(event_id, line): + def already_redacted(tags): + if SCRIPT_NAME + "_redacted" in tags: + return True + return False + + event_tag = SCRIPT_NAME + "_id_{}".format(event_id) + tags = line.tags + + if event_tag in tags and not already_redacted(tags): + return True + + return False + + def redact_string(message): + new_message = "" + + if G.CONFIG.look.redactions == RedactType.STRIKETHROUGH: + plaintext_msg = W.string_remove_color(message, "") + new_message = string_strikethrough(plaintext_msg) + elif G.CONFIG.look.redactions == RedactType.NOTICE: + new_message = message + elif G.CONFIG.look.redactions == RedactType.DELETE: + pass + + return new_message + + lines = self.weechat_buffer.find_lines( + partial(predicate, event.redacts) + ) + + # No line to redact, return early + if not lines: + return + + censor = self.find_nick(event.sender) + + reason = ( + "" + if not event.reason + else ', reason: "{reason}"'.format(reason=event.reason) + ) + + redaction_msg = ( + "{del_color}<{log_color}Message redacted by: " + "{censor}{log_color}{reason}{del_color}>" + "{ncolor}" + ).format( + del_color=W.color("chat_delimiters"), + ncolor=W.color("reset"), + log_color=W.color("logger.color.backlog_line"), + censor=censor, + reason=reason, + ) + + line = lines[0] + message = line.message + tags = line.tags + + new_message = redact_string(message) + message = " ".join(s for s in [new_message, redaction_msg] if s) + tags.append("matrix_redacted") + + line.message = message + line.tags = tags + + for line in lines[1:]: + message = line.message + tags = line.tags + + new_message = redact_string(message) + + if not new_message: + new_message = redaction_msg + elif G.CONFIG.look.redactions == RedactType.NOTICE: + new_message += " {}".format(redaction_msg) + + tags.append("matrix_redacted") + + line.message = new_message + line.tags = tags + + def _handle_redacted_message(self, event): + nick = self.find_nick(event.sender) + date = server_ts_to_weechat(event.server_timestamp) + tags = self.get_event_tags(event) + tags.append(SCRIPT_NAME + "_redacted") + + reason = ( + ', reason: "{reason}"'.format(reason=event.reason) + if event.reason + else "" + ) + + censor = self.find_nick(event.redacter) + + data = ( + "{del_color}<{log_color}Message redacted by: " + "{censor}{log_color}{reason}{del_color}>{ncolor}" + ).format( + del_color=W.color("chat_delimiters"), + ncolor=W.color("reset"), + log_color=W.color("logger.color.backlog_line"), + censor=censor, + reason=reason, + ) + + self.weechat_buffer.message(nick, data, date, tags) + + def _handle_topic(self, event, is_state): + nick = self.find_nick(event.sender) + + self.weechat_buffer.change_topic( + nick, + event.topic, + server_ts_to_weechat(event.server_timestamp), + not is_state, + ) + + @staticmethod + def get_event_tags(event): + # type: (Event) -> List[str] + tags = [SCRIPT_NAME + "_id_{}".format(event.event_id)] + if event.sender_key: + tags.append(SCRIPT_NAME + "_senderkey_{}".format(event.sender_key)) + if event.session_id: + tags.append(SCRIPT_NAME + "_session_id_{}".format( + event.session_id + )) + + return tags + + def _handle_power_level(self, _): + for user_id in self.room.power_levels.users: + if user_id in self.displayed_nicks: + nick = self.find_nick(user_id) + + user = self.weechat_buffer.users[nick] + user.power_level = self.room.power_levels.get_user_level( + user_id + ) + + # There is no way to change the group of a user without + # removing him from the nicklist + self.weechat_buffer.remove_user_from_nicklist(user) + self.weechat_buffer._add_user_to_nicklist(user) + + def handle_state_event(self, event): + if isinstance(event, RoomMemberEvent): + self.handle_membership_events(event, True) + elif isinstance(event, RoomTopicEvent): + self._handle_topic(event, True) + elif isinstance(event, PowerLevelsEvent): + self._handle_power_level(event) + elif isinstance(event, (RoomNameEvent, RoomAliasEvent)): + self.update_buffer_name() + elif isinstance(event, RoomEncryptionEvent): + pass + + def handle_own_message_in_timeline(self, event): + """Check if our own message is already printed if not print it. + This function is called for messages that contain a transaction id + indicating that they were sent out using our own client. If we sent out + a message but never got a valid server response (e.g. due to + disconnects) this function prints them out using data from the next + sync response""" + uuid = UUID(event.transaction_id) + message = self.sent_messages_queue.pop(uuid, None) + + # We already got a response to the room_send_message() API call and + # handled the message, no need to print it out again + if not message: + return + + message.event_id = event.event_id + if uuid in self.printed_before_ack_queue: + self.replace_printed_line_by_uuid( + event.transaction_id, + message + ) + self.printed_before_ack_queue.remove(uuid) + return + + if isinstance(message, OwnAction): + self.self_action(message) + elif isinstance(message, OwnMessage): + self.self_message(message) + return + + def handle_timeline_event(self, event): + # TODO this should be done for every messagetype that gets printed in + # the buffer + if isinstance(event, (RoomMessage, MegolmEvent)): + if (event.sender not in self.displayed_nicks and + event.sender in self.room.users): + + try: + self.unhandled_users.remove(event.sender) + except ValueError: + pass + + self.add_user(event.sender, 0, True, True) + + if event.transaction_id: + self.handle_own_message_in_timeline(event) + return + + if isinstance(event, RoomMemberEvent): + self.handle_membership_events(event, False) + + elif isinstance(event, (RoomNameEvent, RoomAliasEvent)): + self.update_buffer_name() + + elif isinstance(event, RoomTopicEvent): + self._handle_topic(event, False) + + # Emotes are a subclass of RoomMessageText, so put them before the text + # ones + elif isinstance(event, RoomMessageEmote): + nick = self.find_nick(event.sender) + date = server_ts_to_weechat(event.server_timestamp) + + extra_prefix = (self.warning_prefix if event.decrypted + and not event.verified else "") + + self.weechat_buffer.action( + nick, event.body, date, self.get_event_tags(event), + extra_prefix + ) + + elif isinstance(event, RoomMessageText): + nick = self.find_nick(event.sender) + formatted = None + + if event.formatted_body: + formatted = Formatted.from_html(event.formatted_body) + + data = formatted.to_weechat() if formatted else event.body + + extra_prefix = (self.warning_prefix if event.decrypted + and not event.verified else "") + + date = server_ts_to_weechat(event.server_timestamp) + self.weechat_buffer.message( + nick, data, date, self.get_event_tags(event), extra_prefix + ) + + elif isinstance(event, RoomMessageNotice): + nick = self.find_nick(event.sender) + date = server_ts_to_weechat(event.server_timestamp) + extra_prefix = (self.warning_prefix if event.decrypted + and not event.verified else "") + + self.weechat_buffer.notice( + nick, event.body, date, self.get_event_tags(event), + extra_prefix + ) + + elif isinstance(event, RoomMessageMedia): + nick = self.find_nick(event.sender) + date = server_ts_to_weechat(event.server_timestamp) + http_url = Api.mxc_to_http(event.url, self.homeserver.geturl()) + url = http_url if http_url else event.url + + description = "/{}".format(event.body) if event.body else "" + data = "{url}{desc}".format(url=url, desc=description) + + extra_prefix = (self.warning_prefix if event.decrypted + and not event.verified else "") + + self.weechat_buffer.message( + nick, data, date, self.get_event_tags(event), extra_prefix + ) + + elif isinstance(event, RoomEncryptedMedia): + nick = self.find_nick(event.sender) + date = server_ts_to_weechat(event.server_timestamp) + http_url = Api.encrypted_mxc_to_plumb( + event.url, + event.key["k"], + event.hashes["sha256"], + event.iv, + self.homeserver.geturl() + ) + url = http_url if http_url else event.url + + description = "{}".format(event.body) if event.body else "file" + data = ("{del_color}<{ncolor}{desc}{del_color}>{ncolor} " + "{del_color}[{ncolor}{url}{del_color}]{ncolor}").format( + del_color=W.color("chat_delimiters"), + ncolor=W.color("reset"), + desc=description, url=url) + + extra_prefix = (self.warning_prefix if event.decrypted + and not event.verified else "") + + self.weechat_buffer.message( + nick, data, date, self.get_event_tags(event), extra_prefix + ) + + elif isinstance(event, RoomMessageUnknown): + nick = self.find_nick(event.sender) + date = server_ts_to_weechat(event.server_timestamp) + data = ("Unknown message of type {t}").format(t=event.type) + extra_prefix = (self.warning_prefix if event.decrypted + and not event.verified else "") + + self.weechat_buffer.message( + nick, data, date, self.get_event_tags(event), extra_prefix + ) + + elif isinstance(event, RedactionEvent): + self._redact_line(event) + + elif isinstance(event, RedactedEvent): + self._handle_redacted_message(event) + + elif isinstance(event, RoomEncryptionEvent): + message = ( + "This room is encrypted, encryption is " + "currently unsuported. Message sending is disabled for " + "this room." + ) + self.weechat_buffer.error(message) + + elif isinstance(event, PowerLevelsEvent): + self._handle_power_level(event) + + elif isinstance(event, MegolmEvent): + nick = self.find_nick(event.sender) + date = server_ts_to_weechat(event.server_timestamp) + + data = ("{del_color}<{log_color}Unable to decrypt: " + "The sender's device has not sent us " + "the keys for this message{del_color}>{ncolor}").format( + del_color=W.color("chat_delimiters"), + log_color=W.color("logger.color.backlog_line"), + ncolor=W.color("reset")) + session_id_tag = SCRIPT_NAME + "_sessionid_" + event.session_id + self.weechat_buffer.message( + nick, + data, + date, + self.get_event_tags(event) + [session_id_tag] + ) + + self.undecrypted_events.append(event) + + elif isinstance(event, UnknownEvent): + pass + + elif isinstance(event, BadEvent): + nick = self.find_nick(event.sender) + date = server_ts_to_weechat(event.server_timestamp) + data = ("Bad event received, event type: {t}").format(t=event.type) + extra_prefix = self.warning_prefix + + self.weechat_buffer.message( + nick, data, date, self.get_event_tags(event), extra_prefix + ) + + elif isinstance(event, UnknownBadEvent): + self.error("Unkwnown bad event: {}".format( + pprint.pformat(event.event_dict) + )) + + else: + W.prnt( + "", "Unhandled event of type {}.".format(type(event).__name__) + ) + + def self_message(self, message): + # type: (OwnMessage) -> None + nick = self.find_nick(self.room.own_user_id) + data = message.formatted_message.to_weechat() + if message.event_id: + tags = [SCRIPT_NAME + "_id_{}".format(message.event_id)] + else: + tags = [SCRIPT_NAME + "_uuid_{}".format(message.uuid)] + date = message.age + + self.weechat_buffer.self_message(nick, data, date, tags) + + def self_action(self, message): + # type: (OwnMessage) -> None + nick = self.find_nick(self.room.own_user_id) + date = message.age + if message.event_id: + tags = [SCRIPT_NAME + "_id_{}".format(message.event_id)] + else: + tags = [SCRIPT_NAME + "_uuid_{}".format(message.uuid)] + + self.weechat_buffer.self_action( + nick, message.formatted_message.to_weechat(), date, tags + ) + + @staticmethod + def _find_by_uuid_predicate(uuid, line): + uuid_tag = SCRIPT_NAME + "_uuid_{}".format(uuid) + tags = line.tags + + if uuid_tag in tags: + return True + return False + + def mark_message_as_unsent(self, uuid, _): + """Append to already printed lines that are greyed out an error + message""" + lines = self.weechat_buffer.find_lines( + partial(self._find_by_uuid_predicate, uuid) + ) + last_line = lines[-1] + + message = last_line.message + message += (" {del_color}<{ncolor}{error_color}Error sending " + "message{del_color}>{ncolor}").format( + del_color=W.color("chat_delimiters"), + ncolor=W.color("reset"), + error_color=W.color(color_pair( + G.CONFIG.color.error_message_fg, + G.CONFIG.color.error_message_bg))) + + last_line.message = message + + def replace_printed_line_by_uuid(self, uuid, new_message): + """Replace already printed lines that are greyed out with real ones.""" + if isinstance(new_message, OwnAction): + displayed_nick = self.displayed_nicks[self.room.own_user_id] + user = self.weechat_buffer._get_user(displayed_nick) + data = self.weechat_buffer._format_action( + user, + new_message.formatted_message.to_weechat() + ) + new_lines = data.split("\n") + else: + new_lines = new_message.formatted_message.to_weechat().split("\n") + + line_count = len(new_lines) + + lines = self.weechat_buffer.find_lines( + partial(self._find_by_uuid_predicate, uuid), line_count + ) + + for i, line in enumerate(lines): + line.message = new_lines[i] + tags = line.tags + + new_tags = [ + tag for tag in tags + if not tag.startswith(SCRIPT_NAME + "_uuid_") + ] + new_tags.append(SCRIPT_NAME + "_id_" + new_message.event_id) + line.tags = new_tags + + def replace_undecrypted_line(self, event): + """Find a undecrypted message in the buffer and replace it with the now + decrypted event.""" + # TODO different messages need different formatting + # To implement this, refactor out the different formatting code + # snippets to a Formatter class and reuse them here. + if not isinstance(event, RoomMessageText): + return + + def predicate(event_id, line): + event_tag = SCRIPT_NAME + "_id_{}".format(event_id) + if event_tag in line.tags: + return True + return False + + lines = self.weechat_buffer.find_lines( + partial(predicate, event.event_id) + ) + + if not lines: + return + + formatted = None + if event.formatted_body: + formatted = Formatted.from_html(event.formatted_body) + + data = formatted.to_weechat() if formatted else event.body + # TODO this isn't right if the data has multiple lines, that is + # everything is printed on a signle line and newlines are shown as a + # space. + # Weechat should support deleting lines and printing new ones at an + # arbitrary position. + # To implement this without weechat support either only handle single + # line messages or edit the first line in place, print new ones at the + # bottom and sort the buffer lines. + lines[0].message = data + + def old_redacted(self, event): + tags = [ + SCRIPT_NAME + "_message", + "notify_message", + "no_log", + "no_highlight", + ] + reason = ( + ', reason: "{reason}"'.format(reason=event.reason) + if event.reason + else "" + ) + + censor = self.find_nick(event.redacter) + + data = ( + "{del_color}<{log_color}Message redacted by: " + "{censor}{log_color}{reason}{del_color}>{ncolor}" + ).format( + del_color=W.color("chat_delimiters"), + ncolor=W.color("reset"), + log_color=W.color("logger.color.backlog_line"), + censor=censor, + reason=reason, + ) + + tags += self.get_event_tags(event) + nick = self.find_nick(event.sender) + user = self.weechat_buffer._get_user(nick) + date = server_ts_to_weechat(event.server_timestamp) + self.weechat_buffer._print_message(user, data, date, tags) + + def old_message(self, event): + tags = [ + SCRIPT_NAME + "_message", + "notify_message", + "no_log", + "no_highlight", + ] + tags += self.get_event_tags(event) + nick = self.find_nick(event.sender) + + formatted = None + + if event.formatted_body: + formatted = Formatted.from_html(event.formatted_body) + + data = formatted.to_weechat() if formatted else event.body + user = self.weechat_buffer._get_user(nick) + date = server_ts_to_weechat(event.server_timestamp) + self.weechat_buffer._print_message(user, data, date, tags) + + def sort_messages(self): + class LineCopy(object): + def __init__( + self, date, date_printed, tags, prefix, message, highlight + ): + self.date = date + self.date_printed = date_printed + self.tags = tags + self.prefix = prefix + self.message = message + self.highlight = highlight + + @classmethod + def from_line(cls, line): + return cls( + line.date, + line.date_printed, + line.tags, + line.prefix, + line.message, + line.highlight, + ) + + lines = [ + LineCopy.from_line(line) for line in self.weechat_buffer.lines + ] + sorted_lines = sorted(lines, key=lambda line: line.date, reverse=True) + + for line_number, line in enumerate(self.weechat_buffer.lines): + new = sorted_lines[line_number] + line.update( + new.date, new.date_printed, new.tags, new.prefix, new.message + ) + + def handle_backlog(self, response): + self.prev_batch = response.end + + for event in response.chunk: + if isinstance(event, RoomMessageText): + self.old_message(event) + elif isinstance(event, RedactedEvent): + self.old_redacted(event) + + self.sort_messages() + + self.backlog_pending = False + + def handle_joined_room(self, info): + for event in info.state: + self.handle_state_event(event) + + timeline_events = None + + # This is a rejoin, skip already handled events + if not self.joined: + leave_index = None + + for i, event in enumerate(info.timeline.events): + if event.event_id == self.leave_event_id: + leave_index = i + break + + if leave_index: + timeline_events = info.timeline.events[leave_index + 1:] + # Handle our leave as a state event since we're not in the + # nicklist anymore but we're already printed out our leave + self.handle_state_event(info.timeline.events[leave_index]) + else: + timeline_events = info.timeline.events + + # mark that we are now joined + self.joined = True + + else: + timeline_events = info.timeline.events + + for event in timeline_events: + self.handle_timeline_event(event) + + for event in info.account_data: + if isinstance(event, FullyReadEvent): + if event.event_id == self.last_event_id: + current_buffer = W.buffer_search("", "") + + if self.weechat_buffer._ptr == current_buffer: + continue + + W.buffer_set(self.weechat_buffer._ptr, "unread", "") + W.buffer_set(self.weechat_buffer._ptr, "hotlist", "-1") + + # We didn't handle all joined users, the room display name might still + # be outdated because of that, update it now. + if self.unhandled_users: + self.update_buffer_name() + + def handle_left_room(self, info): + self.joined = False + + for event in info.state: + self.handle_state_event(event) + + for event in info.timeline.events: + self.handle_timeline_event(event) + + def error(self, string): + # type: (str) -> None + self.weechat_buffer.error(string) diff --git a/.weechat/python/matrix/colors.py b/.weechat/python/matrix/colors.py new file mode 100644 index 0000000..ceb2589 --- /dev/null +++ b/.weechat/python/matrix/colors.py @@ -0,0 +1,1178 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2008 Nicholas Marriott +# Copyright © 2016 Avi Halachmi +# Copyright © 2018, 2019 Damir Jelić +# Copyright © 2018, 2019 Denis Kasak +# +# Permission to use, copy, modify, and/or distribute this software for +# any purpose with or without fee is hereby granted, provided that the +# above copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER +# RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from __future__ import unicode_literals + +import html +import re +import textwrap + +# pylint: disable=redefined-builtin +from builtins import str +from collections import namedtuple +from typing import List + +import webcolors +from pygments import highlight +from pygments.formatter import Formatter, get_style_by_name +from pygments.lexers import get_lexer_by_name +from pygments.util import ClassNotFound + +from . import globals as G +from .globals import W +from .utils import (string_strikethrough, + string_color_and_reset, + color_pair, + text_block, + colored_text_block) + +try: + from HTMLParser import HTMLParser +except ImportError: + from html.parser import HTMLParser + + +class FormattedString: + __slots__ = ("text", "attributes") + + def __init__(self, text, attributes): + self.attributes = DEFAULT_ATTRIBUTES.copy() + self.attributes.update(attributes) + self.text = text + + +class Formatted(object): + def __init__(self, substrings): + # type: (List[FormattedString]) -> None + self.substrings = substrings + + @property + def textwrapper(self): + quote_pair = color_pair(G.CONFIG.color.quote_fg, + G.CONFIG.color.quote_bg) + return textwrap.TextWrapper( + width=67, + initial_indent="{}> ".format(W.color(quote_pair)), + subsequent_indent="{}> ".format(W.color(quote_pair)), + ) + + def is_formatted(self): + # type: (Formatted) -> bool + for string in self.substrings: + if string.attributes != DEFAULT_ATTRIBUTES: + return True + return False + + # TODO reverse video + @classmethod + def from_input_line(cls, line): + # type: (str) -> Formatted + """Parses the weechat input line and produces formatted strings that + can be later converted to HTML or to a string for weechat's print + functions + """ + text = "" # type: str + substrings = [] # type: List[FormattedString] + attributes = DEFAULT_ATTRIBUTES.copy() + + i = 0 + while i < len(line): + # Bold + if line[i] == "\x02" and not attributes["code"]: + if text: + substrings.append(FormattedString(text, attributes.copy())) + text = "" + attributes["bold"] = not attributes["bold"] + i = i + 1 + + # Markdown inline code + elif line[i] == "`": + if text: + # strip leading and trailing spaces and compress consecutive + # spaces in inline code blocks + if attributes["code"]: + text = text.strip() + text = re.sub(r"\s+", " ", text) + + substrings.append( + FormattedString(text, attributes.copy()) + ) + text = "" + attributes["code"] = not attributes["code"] + i = i + 1 + + # Markdown emphasis + elif line[i] == "*" and not attributes["code"]: + if attributes["italic"] and not line[i - 1].isspace(): + if text: + substrings.append( + FormattedString(text, attributes.copy()) + ) + text = "" + attributes["italic"] = not attributes["italic"] + i = i + 1 + continue + + elif attributes["italic"] and line[i - 1].isspace(): + text = text + line[i] + i = i + 1 + continue + + elif i + 1 < len(line) and line[i + 1].isspace(): + text = text + line[i] + i = i + 1 + continue + + elif i == len(line) - 1: + text = text + line[i] + i = i + 1 + continue + + if text: + substrings.append(FormattedString(text, attributes.copy())) + text = "" + attributes["italic"] = not attributes["italic"] + i = i + 1 + + # Color + elif line[i] == "\x03" and not attributes["code"]: + if text: + substrings.append(FormattedString(text, attributes.copy())) + text = "" + i = i + 1 + + # check if it's a valid color, add it to the attributes + if line[i].isdigit(): + color_string = line[i] + i = i + 1 + + if line[i].isdigit(): + if color_string == "0": + color_string = line[i] + else: + color_string = color_string + line[i] + i = i + 1 + + attributes["fgcolor"] = color_line_to_weechat(color_string) + else: + attributes["fgcolor"] = None + + # check if we have a background color + if line[i] == "," and line[i + 1].isdigit(): + color_string = line[i + 1] + i = i + 2 + + if line[i].isdigit(): + if color_string == "0": + color_string = line[i] + else: + color_string = color_string + line[i] + i = i + 1 + + attributes["bgcolor"] = color_line_to_weechat(color_string) + else: + attributes["bgcolor"] = None + # Reset + elif line[i] == "\x0F" and not attributes["code"]: + if text: + substrings.append(FormattedString(text, attributes.copy())) + text = "" + # Reset all the attributes + attributes = DEFAULT_ATTRIBUTES.copy() + i = i + 1 + + # Italic + elif line[i] == "\x1D" and not attributes["code"]: + if text: + substrings.append(FormattedString(text, attributes.copy())) + text = "" + attributes["italic"] = not attributes["italic"] + i = i + 1 + + # Underline + elif line[i] == "\x1F" and not attributes["code"]: + if text: + substrings.append(FormattedString(text, attributes.copy())) + text = "" + attributes["underline"] = not attributes["underline"] + i = i + 1 + + # Normal text + else: + text = text + line[i] + i = i + 1 + + substrings.append(FormattedString(text, attributes)) + return cls(substrings) + + @classmethod + def from_html(cls, html): + # type: (str) -> Formatted + parser = MatrixHtmlParser() + parser.feed(html) + return cls(parser.get_substrings()) + + def to_html(self): + def add_attribute(string, name, value): + if name == "bold" and value: + return "{bold_on}{text}{bold_off}".format( + bold_on="", text=string, bold_off="" + ) + if name == "italic" and value: + return "{italic_on}{text}{italic_off}".format( + italic_on="", text=string, italic_off="" + ) + if name == "underline" and value: + return "{underline_on}{text}{underline_off}".format( + underline_on="", text=string, underline_off="" + ) + if name == "strikethrough" and value: + return "{strike_on}{text}{strike_off}".format( + strike_on="", text=string, strike_off="" + ) + if name == "quote" and value: + return "{quote_on}{text}{quote_off}".format( + quote_on="
", + text=string, + quote_off="
", + ) + if name == "code" and value: + return "{code_on}{text}{code_off}".format( + code_on="", text=string, code_off="" + ) + + return string + + def add_color(string, fgcolor, bgcolor): + fgcolor_string = "" + bgcolor_string = "" + + if fgcolor: + fgcolor_string = " data-mx-color={}".format( + color_weechat_to_html(fgcolor) + ) + + if bgcolor: + bgcolor_string = " data-mx-bg-color={}".format( + color_weechat_to_html(bgcolor) + ) + + return "{color_on}{text}{color_off}".format( + color_on="".format( + fg=fgcolor_string, + bg=bgcolor_string + ), + text=string, + color_off="", + ) + + def format_string(formatted_string): + text = formatted_string.text + attributes = formatted_string.attributes.copy() + + if attributes["code"]: + if attributes["preformatted"]: + # XXX: This can't really happen since there's no way of + # creating preformatted code blocks in weechat (because + # there is not multiline input), but I'm creating this + # branch as a note that it should be handled once we do + # implement them. + pass + else: + text = add_attribute(text, "code", True) + attributes.pop("code") + + elif attributes["fgcolor"] or attributes["bgcolor"]: + text = add_color( + text, + attributes["fgcolor"], + attributes["bgcolor"] + ) + else: + for key, value in attributes.items(): + text = add_attribute(text, key, value) + + return text + + html_string = map(format_string, self.substrings) + return "".join(html_string) + + # TODO do we want at least some formatting using unicode + # (strikethrough, quotes)? + def to_plain(self): + # type: () -> str + def strip_atribute(string, _, __): + return string + + def format_string(formatted_string): + text = formatted_string.text + attributes = formatted_string.attributes + + for key, value in attributes.items(): + text = strip_atribute(text, key, value) + return text + + plain_string = map(format_string, self.substrings) + return "".join(plain_string) + + def to_weechat(self): + def add_attribute(string, name, value, attributes): + if not value: + return string + elif name == "bold": + return "{bold_on}{text}{bold_off}".format( + bold_on=W.color("bold"), + text=string, + bold_off=W.color("-bold"), + ) + elif name == "italic": + return "{italic_on}{text}{italic_off}".format( + italic_on=W.color("italic"), + text=string, + italic_off=W.color("-italic"), + ) + elif name == "underline": + return "{underline_on}{text}{underline_off}".format( + underline_on=W.color("underline"), + text=string, + underline_off=W.color("-underline"), + ) + elif name == "strikethrough": + return string_strikethrough(string) + elif name == "quote": + return self.textwrapper.fill( + W.string_remove_color(string.replace("\n", ""), "") + ) + elif name == "code": + code_color_pair = color_pair( + G.CONFIG.color.untagged_code_fg, + G.CONFIG.color.untagged_code_bg + ) + + margin = G.CONFIG.look.code_block_margin + + if attributes["preformatted"]: + # code block + + try: + lexer = get_lexer_by_name(value) + except ClassNotFound: + if G.CONFIG.look.code_blocks: + return colored_text_block( + string, + margin=margin, + color_pair=code_color_pair) + else: + return string_color_and_reset(string, + code_color_pair) + + try: + style = get_style_by_name(G.CONFIG.look.pygments_style) + except ClassNotFound: + style = "native" + + if G.CONFIG.look.code_blocks: + code_block = text_block(string, margin=margin) + else: + code_block = string + + # highlight adds a newline to the end of the string, remove + # it from the output + highlighted_code = highlight( + code_block, + lexer, + WeechatFormatter(style=style) + ).rstrip() + + return highlighted_code + else: + return string_color_and_reset(string, code_color_pair) + elif name == "fgcolor": + return "{color_on}{text}{color_off}".format( + color_on=W.color(value), + text=string, + color_off=W.color("resetcolor"), + ) + elif name == "bgcolor": + return "{color_on}{text}{color_off}".format( + color_on=W.color("," + value), + text=string, + color_off=W.color("resetcolor"), + ) + else: + return string + + def format_string(formatted_string): + text = formatted_string.text + attributes = formatted_string.attributes + + # We need to handle strikethrough first, since doing + # a strikethrough followed by other attributes succeeds in the + # terminal, but doing it the other way around results in garbage. + if "strikethrough" in attributes: + text = add_attribute( + text, + "strikethrough", + attributes["strikethrough"], + attributes + ) + attributes.pop("strikethrough") + + def indent(text, prefix): + return prefix + text.replace("\n", "\n{}".format(prefix)) + + for key, value in attributes.items(): + if not value: + continue + + # Don't use textwrap to quote the code + if key == "quote" and attributes["code"]: + continue + + # Reflow inline code blocks + if key == "code" and not attributes["preformatted"]: + text = text.strip().replace('\n', ' ') + + text = add_attribute(text, key, value, attributes) + + # If we're quoted code add quotation marks now. + if key == "code" and attributes["quote"]: + fg = G.CONFIG.color.quote_fg + bg = G.CONFIG.color.quote_bg + text = indent( + text, + string_color_and_reset(">", color_pair(fg, bg)) + " ", + ) + + # If we're code don't remove multiple newlines blindly + if attributes["code"]: + return text + return re.sub(r"\n+", "\n", text) + + weechat_strings = map(format_string, self.substrings) + + # Remove duplicate \n elements from the list + strings = [] + for string in weechat_strings: + if len(strings) == 0 or string != "\n" or string != strings[-1]: + strings.append(string) + + return "".join(strings).strip() + + +# TODO this should be a typed dict. +DEFAULT_ATTRIBUTES = { + "bold": False, + "italic": False, + "underline": False, + "strikethrough": False, + "preformatted": False, + "quote": False, + "code": None, + "fgcolor": None, + "bgcolor": None, +} + + +class MatrixHtmlParser(HTMLParser): + # TODO bullets + def __init__(self): + HTMLParser.__init__(self) + self.text = "" # type: str + self.substrings = [] # type: List[FormattedString] + self.attributes = DEFAULT_ATTRIBUTES.copy() + + def unescape(self, text): + """Shim to unescape HTML in both Python 2 and 3. + + The instance method was deprecated in Python 3 and html.unescape + doesn't exist in Python 2 so this is needed. + """ + try: + return html.unescape(text) + except AttributeError: + return HTMLParser.unescape(self, text) + + def add_substring(self, text, attrs): + fmt_string = FormattedString(text, attrs) + self.substrings.append(fmt_string) + + def _toggle_attribute(self, attribute): + if self.text: + self.add_substring(self.text, self.attributes.copy()) + self.text = "" + self.attributes[attribute] = not self.attributes[attribute] + + def handle_starttag(self, tag, attrs): + if tag == "strong": + self._toggle_attribute("bold") + elif tag == "em": + self._toggle_attribute("italic") + elif tag == "u": + self._toggle_attribute("underline") + elif tag == "del": + self._toggle_attribute("strikethrough") + elif tag == "blockquote": + self._toggle_attribute("quote") + elif tag == "pre": + self._toggle_attribute("preformatted") + elif tag == "code": + lang = None + + for key, value in attrs: + if key == "class": + if value.startswith("language-"): + lang = value.split("-", 1)[1] + + lang = lang or "unknown" + + if self.text: + self.add_substring(self.text, self.attributes.copy()) + self.text = "" + self.attributes["code"] = lang + elif tag == "p": + if self.text: + self.add_substring(self.text, self.attributes.copy()) + self.text = "\n" + self.add_substring(self.text, DEFAULT_ATTRIBUTES.copy()) + self.text = "" + elif tag == "br": + if self.text: + self.add_substring(self.text, self.attributes.copy()) + self.text = "\n" + self.add_substring(self.text, DEFAULT_ATTRIBUTES.copy()) + self.text = "" + elif tag == "font": + for key, value in attrs: + if key in ["data-mx-color", "color"]: + color = color_html_to_weechat(value) + + if not color: + continue + + if self.text: + self.add_substring(self.text, self.attributes.copy()) + self.text = "" + self.attributes["fgcolor"] = color + + elif key in ["data-mx-bg-color"]: + color = color_html_to_weechat(value) + if not color: + continue + + if self.text: + self.add_substring(self.text, self.attributes.copy()) + self.text = "" + self.attributes["bgcolor"] = color + + else: + pass + + def handle_endtag(self, tag): + if tag == "strong": + self._toggle_attribute("bold") + elif tag == "em": + self._toggle_attribute("italic") + elif tag == "u": + self._toggle_attribute("underline") + elif tag == "del": + self._toggle_attribute("strikethrough") + elif tag == "pre": + self._toggle_attribute("preformatted") + elif tag == "code": + if self.text: + self.add_substring(self.text, self.attributes.copy()) + self.text = "" + self.attributes["code"] = None + elif tag == "blockquote": + self._toggle_attribute("quote") + self.text = "\n" + self.add_substring(self.text, DEFAULT_ATTRIBUTES.copy()) + self.text = "" + elif tag == "font": + if self.text: + self.add_substring(self.text, self.attributes.copy()) + self.text = "" + self.attributes["fgcolor"] = None + else: + pass + + def handle_data(self, data): + self.text += data + + def handle_entityref(self, name): + self.text += self.unescape("&{};".format(name)) + + def handle_charref(self, name): + self.text += self.unescape("&#{};".format(name)) + + def get_substrings(self): + if self.text: + self.add_substring(self.text, self.attributes.copy()) + + return self.substrings + + +def color_line_to_weechat(color_string): + # type: (str) -> str + line_colors = { + "0": "white", + "1": "black", + "2": "blue", + "3": "green", + "4": "lightred", + "5": "red", + "6": "magenta", + "7": "brown", + "8": "yellow", + "9": "lightgreen", + "10": "cyan", + "11": "lightcyan", + "12": "lightblue", + "13": "lightmagenta", + "14": "darkgray", + "15": "gray", + "16": "52", + "17": "94", + "18": "100", + "19": "58", + "20": "22", + "21": "29", + "22": "23", + "23": "24", + "24": "17", + "25": "54", + "26": "53", + "27": "89", + "28": "88", + "29": "130", + "30": "142", + "31": "64", + "32": "28", + "33": "35", + "34": "30", + "35": "25", + "36": "18", + "37": "91", + "38": "90", + "39": "125", + "40": "124", + "41": "166", + "42": "184", + "43": "106", + "44": "34", + "45": "49", + "46": "37", + "47": "33", + "48": "19", + "49": "129", + "50": "127", + "51": "161", + "52": "196", + "53": "208", + "54": "226", + "55": "154", + "56": "46", + "57": "86", + "58": "51", + "59": "75", + "60": "21", + "61": "171", + "62": "201", + "63": "198", + "64": "203", + "65": "215", + "66": "227", + "67": "191", + "68": "83", + "69": "122", + "70": "87", + "71": "111", + "72": "63", + "73": "177", + "74": "207", + "75": "205", + "76": "217", + "77": "223", + "78": "229", + "79": "193", + "80": "157", + "81": "158", + "82": "159", + "83": "153", + "84": "147", + "85": "183", + "86": "219", + "87": "212", + "88": "16", + "89": "233", + "90": "235", + "91": "237", + "92": "239", + "93": "241", + "94": "244", + "95": "247", + "96": "250", + "97": "254", + "98": "231", + "99": "default", + } + + assert color_string in line_colors + + return line_colors[color_string] + + +# The functions color_dist_sq(), color_to_6cube(), and color_find_rgb +# are python ports of the same named functions from the tmux +# source, they are under the copyright of Nicholas Marriott, and Avi Halachmi +# under the ISC license. +# More info: https://github.com/tmux/tmux/blob/master/colour.c + + +def color_dist_sq(R, G, B, r, g, b): + # pylint: disable=invalid-name,too-many-arguments + # type: (int, int, int, int, int, int) -> int + return (R - r) * (R - r) + (G - g) * (G - g) + (B - b) * (B - b) + + +def color_to_6cube(v): + # pylint: disable=invalid-name + # type: (int) -> int + if v < 48: + return 0 + if v < 114: + return 1 + return (v - 35) // 40 + + +def color_find_rgb(r, g, b): + # type: (int, int, int) -> int + """Convert an RGB triplet to the xterm(1) 256 color palette. + + xterm provides a 6x6x6 color cube (16 - 231) and 24 greys (232 - 255). + We map our RGB color to the closest in the cube, also work out the + closest grey, and use the nearest of the two. + + Note that the xterm has much lower resolution for darker colors (they + are not evenly spread out), so our 6 levels are not evenly spread: 0x0, + 0x5f (95), 0x87 (135), 0xaf (175), 0xd7 (215) and 0xff (255). Greys are + more evenly spread (8, 18, 28 ... 238). + """ + # pylint: disable=invalid-name + q2c = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff] + + # Map RGB to 6x6x6 cube. + qr = color_to_6cube(r) + qg = color_to_6cube(g) + qb = color_to_6cube(b) + + cr = q2c[qr] + cg = q2c[qg] + cb = q2c[qb] + + # If we have hit the color exactly, return early. + if cr == r and cg == g and cb == b: + return 16 + (36 * qr) + (6 * qg) + qb + + # Work out the closest grey (average of RGB). + grey_avg = (r + g + b) // 3 + + if grey_avg > 238: + grey_idx = 23 + else: + grey_idx = (grey_avg - 3) // 10 + + grey = 8 + (10 * grey_idx) + + # Is grey or 6x6x6 color closest? + d = color_dist_sq(cr, cg, cb, r, g, b) + + if color_dist_sq(grey, grey, grey, r, g, b) < d: + idx = 232 + grey_idx + else: + idx = 16 + (36 * qr) + (6 * qg) + qb + + return idx + + +def color_html_to_weechat(color): + # type: (str) -> str + # yapf: disable + weechat_basic_colors = { + (0, 0, 0): "black", # 0 + (128, 0, 0): "red", # 1 + (0, 128, 0): "green", # 2 + (128, 128, 0): "brown", # 3 + (0, 0, 128): "blue", # 4 + (128, 0, 128): "magenta", # 5 + (0, 128, 128): "cyan", # 6 + (192, 192, 192): "default", # 7 + (128, 128, 128): "gray", # 8 + (255, 0, 0): "lightred", # 9 + (0, 255, 0): "lightgreen", # 10 + (255, 255, 0): "yellow", # 11 + (0, 0, 255): "lightblue", # 12 + (255, 0, 255): "lightmagenta", # 13 + (0, 255, 255): "lightcyan", # 14 + (255, 255, 255): "white", # 15 + } + # yapf: enable + + try: + rgb_color = webcolors.html5_parse_legacy_color(color) + except ValueError: + return "" + + if rgb_color in weechat_basic_colors: + return weechat_basic_colors[rgb_color] + + return str(color_find_rgb(*rgb_color)) + + +def color_weechat_to_html(color): + # type: (str) -> str + # yapf: disable + weechat_basic_colors = { + "black": "0", + "red": "1", + "green": "2", + "brown": "3", + "blue": "4", + "magenta": "5", + "cyan": "6", + "default": "7", + "gray": "8", + "lightred": "9", + "lightgreen": "10", + "yellow": "11", + "lightblue": "12", + "lightmagenta": "13", + "lightcyan": "14", + "white": "15", + } + hex_colors = { + "0": "#000000", + "1": "#800000", + "2": "#008000", + "3": "#808000", + "4": "#000080", + "5": "#800080", + "6": "#008080", + "7": "#c0c0c0", + "8": "#808080", + "9": "#ff0000", + "10": "#00ff00", + "11": "#ffff00", + "12": "#0000ff", + "13": "#ff00ff", + "14": "#00ffff", + "15": "#ffffff", + "16": "#000000", + "17": "#00005f", + "18": "#000087", + "19": "#0000af", + "20": "#0000d7", + "21": "#0000ff", + "22": "#005f00", + "23": "#005f5f", + "24": "#005f87", + "25": "#005faf", + "26": "#005fd7", + "27": "#005fff", + "28": "#008700", + "29": "#00875f", + "30": "#008787", + "31": "#0087af", + "32": "#0087d7", + "33": "#0087ff", + "34": "#00af00", + "35": "#00af5f", + "36": "#00af87", + "37": "#00afaf", + "38": "#00afd7", + "39": "#00afff", + "40": "#00d700", + "41": "#00d75f", + "42": "#00d787", + "43": "#00d7af", + "44": "#00d7d7", + "45": "#00d7ff", + "46": "#00ff00", + "47": "#00ff5f", + "48": "#00ff87", + "49": "#00ffaf", + "50": "#00ffd7", + "51": "#00ffff", + "52": "#5f0000", + "53": "#5f005f", + "54": "#5f0087", + "55": "#5f00af", + "56": "#5f00d7", + "57": "#5f00ff", + "58": "#5f5f00", + "59": "#5f5f5f", + "60": "#5f5f87", + "61": "#5f5faf", + "62": "#5f5fd7", + "63": "#5f5fff", + "64": "#5f8700", + "65": "#5f875f", + "66": "#5f8787", + "67": "#5f87af", + "68": "#5f87d7", + "69": "#5f87ff", + "70": "#5faf00", + "71": "#5faf5f", + "72": "#5faf87", + "73": "#5fafaf", + "74": "#5fafd7", + "75": "#5fafff", + "76": "#5fd700", + "77": "#5fd75f", + "78": "#5fd787", + "79": "#5fd7af", + "80": "#5fd7d7", + "81": "#5fd7ff", + "82": "#5fff00", + "83": "#5fff5f", + "84": "#5fff87", + "85": "#5fffaf", + "86": "#5fffd7", + "87": "#5fffff", + "88": "#870000", + "89": "#87005f", + "90": "#870087", + "91": "#8700af", + "92": "#8700d7", + "93": "#8700ff", + "94": "#875f00", + "95": "#875f5f", + "96": "#875f87", + "97": "#875faf", + "98": "#875fd7", + "99": "#875fff", + "100": "#878700", + "101": "#87875f", + "102": "#878787", + "103": "#8787af", + "104": "#8787d7", + "105": "#8787ff", + "106": "#87af00", + "107": "#87af5f", + "108": "#87af87", + "109": "#87afaf", + "110": "#87afd7", + "111": "#87afff", + "112": "#87d700", + "113": "#87d75f", + "114": "#87d787", + "115": "#87d7af", + "116": "#87d7d7", + "117": "#87d7ff", + "118": "#87ff00", + "119": "#87ff5f", + "120": "#87ff87", + "121": "#87ffaf", + "122": "#87ffd7", + "123": "#87ffff", + "124": "#af0000", + "125": "#af005f", + "126": "#af0087", + "127": "#af00af", + "128": "#af00d7", + "129": "#af00ff", + "130": "#af5f00", + "131": "#af5f5f", + "132": "#af5f87", + "133": "#af5faf", + "134": "#af5fd7", + "135": "#af5fff", + "136": "#af8700", + "137": "#af875f", + "138": "#af8787", + "139": "#af87af", + "140": "#af87d7", + "141": "#af87ff", + "142": "#afaf00", + "143": "#afaf5f", + "144": "#afaf87", + "145": "#afafaf", + "146": "#afafd7", + "147": "#afafff", + "148": "#afd700", + "149": "#afd75f", + "150": "#afd787", + "151": "#afd7af", + "152": "#afd7d7", + "153": "#afd7ff", + "154": "#afff00", + "155": "#afff5f", + "156": "#afff87", + "157": "#afffaf", + "158": "#afffd7", + "159": "#afffff", + "160": "#d70000", + "161": "#d7005f", + "162": "#d70087", + "163": "#d700af", + "164": "#d700d7", + "165": "#d700ff", + "166": "#d75f00", + "167": "#d75f5f", + "168": "#d75f87", + "169": "#d75faf", + "170": "#d75fd7", + "171": "#d75fff", + "172": "#d78700", + "173": "#d7875f", + "174": "#d78787", + "175": "#d787af", + "176": "#d787d7", + "177": "#d787ff", + "178": "#d7af00", + "179": "#d7af5f", + "180": "#d7af87", + "181": "#d7afaf", + "182": "#d7afd7", + "183": "#d7afff", + "184": "#d7d700", + "185": "#d7d75f", + "186": "#d7d787", + "187": "#d7d7af", + "188": "#d7d7d7", + "189": "#d7d7ff", + "190": "#d7ff00", + "191": "#d7ff5f", + "192": "#d7ff87", + "193": "#d7ffaf", + "194": "#d7ffd7", + "195": "#d7ffff", + "196": "#ff0000", + "197": "#ff005f", + "198": "#ff0087", + "199": "#ff00af", + "200": "#ff00d7", + "201": "#ff00ff", + "202": "#ff5f00", + "203": "#ff5f5f", + "204": "#ff5f87", + "205": "#ff5faf", + "206": "#ff5fd7", + "207": "#ff5fff", + "208": "#ff8700", + "209": "#ff875f", + "210": "#ff8787", + "211": "#ff87af", + "212": "#ff87d7", + "213": "#ff87ff", + "214": "#ffaf00", + "215": "#ffaf5f", + "216": "#ffaf87", + "217": "#ffafaf", + "218": "#ffafd7", + "219": "#ffafff", + "220": "#ffd700", + "221": "#ffd75f", + "222": "#ffd787", + "223": "#ffd7af", + "224": "#ffd7d7", + "225": "#ffd7ff", + "226": "#ffff00", + "227": "#ffff5f", + "228": "#ffff87", + "229": "#ffffaf", + "230": "#ffffd7", + "231": "#ffffff", + "232": "#080808", + "233": "#121212", + "234": "#1c1c1c", + "235": "#262626", + "236": "#303030", + "237": "#3a3a3a", + "238": "#444444", + "239": "#4e4e4e", + "240": "#585858", + "241": "#626262", + "242": "#6c6c6c", + "243": "#767676", + "244": "#808080", + "245": "#8a8a8a", + "246": "#949494", + "247": "#9e9e9e", + "248": "#a8a8a8", + "249": "#b2b2b2", + "250": "#bcbcbc", + "251": "#c6c6c6", + "252": "#d0d0d0", + "253": "#dadada", + "254": "#e4e4e4", + "255": "#eeeeee" + } + + # yapf: enable + if color in weechat_basic_colors: + return hex_colors[weechat_basic_colors[color]] + return hex_colors[color] + + +class WeechatFormatter(Formatter): + def __init__(self, **options): + Formatter.__init__(self, **options) + self.styles = {} + + for token, style in self.style: + start = end = "" + if style["color"]: + start += "{}".format( + W.color(color_html_to_weechat(str(style["color"]))) + ) + end = "{}".format(W.color("resetcolor")) + end + if style["bold"]: + start += W.color("bold") + end = W.color("-bold") + end + if style["italic"]: + start += W.color("italic") + end = W.color("-italic") + end + if style["underline"]: + start += W.color("underline") + end = W.color("-underline") + end + self.styles[token] = (start, end) + + def format(self, tokensource, outfile): + lastval = "" + lasttype = None + + for ttype, value in tokensource: + while ttype not in self.styles: + ttype = ttype.parent + + if ttype == lasttype: + lastval += value + else: + if lastval: + stylebegin, styleend = self.styles[lasttype] + outfile.write(stylebegin + lastval + styleend) + # set lastval/lasttype to current values + lastval = value + lasttype = ttype + + if lastval: + stylebegin, styleend = self.styles[lasttype] + outfile.write(stylebegin + lastval + styleend) diff --git a/.weechat/python/matrix/commands.py b/.weechat/python/matrix/commands.py new file mode 100644 index 0000000..99b6889 --- /dev/null +++ b/.weechat/python/matrix/commands.py @@ -0,0 +1,1836 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2018, 2019 Damir Jelić +# Copyright © 2018, 2019 Denis Kasak +# +# Permission to use, copy, modify, and/or distribute this software for +# any purpose with or without fee is hereby granted, provided that the +# above copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER +# RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from __future__ import unicode_literals +import argparse +import os +import re +from builtins import str +from future.moves.itertools import zip_longest +from collections import defaultdict +from functools import partial +from nio import EncryptionError, LocalProtocolError + +from . import globals as G +from .colors import Formatted +from .globals import SERVERS, W, UPLOADS, SCRIPT_NAME +from .server import MatrixServer +from .utf import utf8_decode +from .utils import key_from_value +from .uploads import UploadsBuffer, Upload + + +class ParseError(Exception): + pass + + +class WeechatArgParse(argparse.ArgumentParser): + def print_usage(self, file=None): + pass + + def error(self, message): + message = ( + "{prefix}Error: {message} for command {command} " + "(see /help {command})" + ).format(prefix=W.prefix("error"), message=message, command=self.prog) + W.prnt("", message) + raise ParseError + + +class WeechatCommandParser(object): + @staticmethod + def _run_parser(parser, args): + try: + parsed_args = parser.parse_args(args.split()) + return parsed_args + except ParseError: + return None + + @staticmethod + def topic(args): + parser = WeechatArgParse(prog="topic") + + parser.add_argument("-delete", action="store_true") + parser.add_argument("topic", nargs="*") + + return WeechatCommandParser._run_parser(parser, args) + + @staticmethod + def kick(args): + parser = WeechatArgParse(prog="kick") + parser.add_argument("user_id") + parser.add_argument("reason", nargs="*") + + return WeechatCommandParser._run_parser(parser, args) + + @staticmethod + def invite(args): + parser = WeechatArgParse(prog="invite") + parser.add_argument("user_id") + + return WeechatCommandParser._run_parser(parser, args) + + @staticmethod + def join(args): + parser = WeechatArgParse(prog="join") + parser.add_argument("room_id") + return WeechatCommandParser._run_parser(parser, args) + + @staticmethod + def part(args): + parser = WeechatArgParse(prog="part") + parser.add_argument("room_id", nargs="?") + return WeechatCommandParser._run_parser(parser, args) + + @staticmethod + def devices(args): + parser = WeechatArgParse(prog="devices") + subparsers = parser.add_subparsers(dest="subcommand") + subparsers.add_parser("list") + + delete_parser = subparsers.add_parser("delete") + delete_parser.add_argument("device_id") + + name_parser = subparsers.add_parser("set-name") + name_parser.add_argument("device_id") + name_parser.add_argument("device_name", nargs="*") + + return WeechatCommandParser._run_parser(parser, args) + + @staticmethod + def olm(args): + parser = WeechatArgParse(prog="olm") + subparsers = parser.add_subparsers(dest="subcommand") + + info_parser = subparsers.add_parser("info") + info_parser.add_argument( + "category", nargs="?", default="private", + choices=[ + "all", + "blacklisted", + "private", + "unverified", + "verified", + "ignored" + ]) + info_parser.add_argument("filter", nargs="?") + + verify_parser = subparsers.add_parser("verify") + verify_parser.add_argument("user_filter") + verify_parser.add_argument("device_filter", nargs="?") + + unverify_parser = subparsers.add_parser("unverify") + unverify_parser.add_argument("user_filter") + unverify_parser.add_argument("device_filter", nargs="?") + + blacklist_parser = subparsers.add_parser("blacklist") + blacklist_parser.add_argument("user_filter") + blacklist_parser.add_argument("device_filter", nargs="?") + + unblacklist_parser = subparsers.add_parser("unblacklist") + unblacklist_parser.add_argument("user_filter") + unblacklist_parser.add_argument("device_filter", nargs="?") + + ignore_parser = subparsers.add_parser("ignore") + ignore_parser.add_argument("user_filter") + ignore_parser.add_argument("device_filter", nargs="?") + + unignore_parser = subparsers.add_parser("unignore") + unignore_parser.add_argument("user_filter") + unignore_parser.add_argument("device_filter", nargs="?") + + export_parser = subparsers.add_parser("export") + export_parser.add_argument("file") + export_parser.add_argument("passphrase") + + import_parser = subparsers.add_parser("import") + import_parser.add_argument("file") + import_parser.add_argument("passphrase") + + sas_parser = subparsers.add_parser("verification") + sas_parser.add_argument( + "action", + choices=[ + "start", + "accept", + "confirm", + "cancel", + ]) + sas_parser.add_argument("user_id") + sas_parser.add_argument("device_id") + + return WeechatCommandParser._run_parser(parser, args) + + @staticmethod + def room(args): + parser = WeechatArgParse(prog="room") + subparsers = parser.add_subparsers(dest="subcommand") + typing_notification = subparsers.add_parser("typing-notifications") + typing_notification.add_argument( + "state", + choices=["enable", "disable", "toggle"] + ) + + read_markers = subparsers.add_parser("read-markers") + read_markers.add_argument( + "state", + choices=["enable", "disable", "toggle"] + ) + + return WeechatCommandParser._run_parser(parser, args) + + @staticmethod + def uploads(args): + parser = WeechatArgParse(prog="uploads") + subparsers = parser.add_subparsers(dest="subcommand") + subparsers.add_parser("list") + subparsers.add_parser("listfull") + subparsers.add_parser("up") + subparsers.add_parser("down") + + return WeechatCommandParser._run_parser(parser, args) + + @staticmethod + def upload(args): + parser = WeechatArgParse(prog="upload") + parser.add_argument("file") + return WeechatCommandParser._run_parser(parser, args) + + +def grouper(iterable, n, fillvalue=None): + "Collect data into fixed-length chunks or blocks" + # grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx" + args = [iter(iterable)] * n + return zip_longest(*args, fillvalue=fillvalue) + + +def partition_key(key): + groups = grouper(key, 4, " ") + return ' '.join(''.join(g) for g in groups) + + +def hook_commands(): + W.hook_command( + # Command name and short description + "matrix", + "Matrix chat protocol command", + # Synopsis + ( + "server add [:] ||" + "server delete|list|listfull ||" + "connect ||" + "disconnect ||" + "reconnect ||" + "help " + ), + # Description + ( + " server: list, add, or remove Matrix servers\n" + " connect: connect to Matrix servers\n" + "disconnect: disconnect from one or all Matrix servers\n" + " reconnect: reconnect to server(s)\n" + " help: show detailed command help\n\n" + "Use /matrix help [command] to find out more.\n" + ), + # Completions + ( + "server %(matrix_server_commands)|%* ||" + "connect %(matrix_servers) ||" + "disconnect %(matrix_servers) ||" + "reconnect %(matrix_servers) ||" + "help %(matrix_commands)" + ), + # Function name + "matrix_command_cb", + "", + ) + + W.hook_command( + # Command name and short description + "redact", + "redact messages", + # Synopsis + ('[:""] []'), + # Description + ( + " event-id: event id of the message that will be redacted\n" + "message-part: an initial part of the message (ignored, only " + "used\n" + " as visual feedback when using completion)\n" + " reason: the redaction reason\n" + ), + # Completions + ("%(matrix_messages)"), + # Function name + "matrix_redact_command_cb", + "", + ) + + W.hook_command( + # Command name and short description + "topic", + "get/set the room topic", + # Synopsis + ("[|-delete]"), + # Description + (" topic: topic to set\n" "-delete: delete room topic"), + # Completions + "", + # Callback + "matrix_topic_command_cb", + "", + ) + + W.hook_command( + # Command name and short description + "me", + "send an emote message to the current room", + # Synopsis + (""), + # Description + ("message: message to send"), + # Completions + "", + # Callback + "matrix_me_command_cb", + "", + ) + + W.hook_command( + # Command name and short description + "kick", + "kick a user from the current room", + # Synopsis + (" []"), + # Description + ( + "user-id: user-id to kick\n" + " reason: reason why the user was kicked" + ), + # Completions + ("%(matrix_users)"), + # Callback + "matrix_kick_command_cb", + "", + ) + + W.hook_command( + # Command name and short description + "invite", + "invite a user to the current room", + # Synopsis + (""), + # Description + ("user-id: user-id to invite"), + # Completions + ("%(matrix_users)"), + # Callback + "matrix_invite_command_cb", + "", + ) + + W.hook_command( + # Command name and short description + "join", + "join a room", + # Synopsis + ("|"), + # Description + ( + " room-id: room-id of the room to join\n" + "room-alias: room alias of the room to join" + ), + # Completions + "", + # Callback + "matrix_join_command_cb", + "", + ) + + W.hook_command( + # Command name and short description + "part", + "leave a room", + # Synopsis + ("[]"), + # Description + (" room-name: room name of the room to leave"), + # Completions + "", + # Callback + "matrix_part_command_cb", + "", + ) + + W.hook_command( + # Command name and short description + "devices", + "list, delete or rename matrix devices", + # Synopsis + ("list ||" + "delete ||" + "set-name " + ), + # Description + ("device-id: device id of the device to delete\n" + " name: new device name to set\n"), + # Completions + ("list ||" + "delete %(matrix_own_devices) ||" + "set-name %(matrix_own_devices)"), + # Callback + "matrix_devices_command_cb", + "", + ) + + W.hook_command( + # Command name and short description + "olm", + "Matrix olm encryption configuration command", + # Synopsis + ("info all|blacklisted|ignored|private|unverified|verified ||" + "blacklist ||" + "unverify ||" + "verify ||" + "verification start|accept|cancel|confirm ||" + "ignore ||" + "unignore ||" + "export ||" + "import " + ), + # Description + (" info: show info about known devices and their keys\n" + " blacklist: blacklist a device\n" + "unblacklist: unblacklist a device\n" + " unverify: unverify a device\n" + " verify: verify a device\n" + " ignore: ignore an unverifiable but non-blacklist-worthy device\n" + " unignore: unignore a device\n" + "verification: manage interactive device verification\n" + " export: export encryption keys\n" + " import: import encryption keys\n\n" + "Examples:" + "\n /olm verify @example:example.com *" + "\n /olm info all example*" + ), + # Completions + ('info all|blacklisted|ignored|private|unverified|verified ||' + 'blacklist %(olm_user_ids) %(olm_devices) ||' + 'unblacklist %(olm_user_ids) %(olm_devices) ||' + 'unverify %(olm_user_ids) %(olm_devices) ||' + 'verify %(olm_user_ids) %(olm_devices) ||' + 'verification start|accept|cancel|confirm %(olm_user_ids) %(olm_devices) ||' + 'ignore %(olm_user_ids) %(olm_devices) ||' + 'unignore %(olm_user_ids) %(olm_devices) ||' + 'export %(filename) ||' + 'import %(filename)' + ), + # Function name + 'matrix_olm_command_cb', + '') + + W.hook_command( + # Command name and short description + "room", + "change room state", + # Synopsis + ("typing-notifications ||" + "read-markers " + ), + # Description + ("state: one of enable, disable or toggle\n"), + # Completions + ("typing-notifications enable|disable|toggle||" + "read-markers enable|disable|toggle" + ), + # Callback + "matrix_room_command_cb", + "", + ) + + # W.hook_command( + # # Command name and short description + # "uploads", + # "Open the uploads buffer or list uploads in the core buffer", + # # Synopsis + # ("list||" + # "listfull" + # ), + # # Description + # (""), + # # Completions + # ("list ||" + # "listfull"), + # # Callback + # "matrix_uploads_command_cb", + # "", + # ) + + W.hook_command( + # Command name and short description + "upload", + "Upload a file to a room", + # Synopsis + (""), + # Description + (""), + # Completions + ("%(filename)"), + # Callback + "matrix_upload_command_cb", + "", + ) + + W.hook_command( + # Command name and short description + "send-anyways", + "Send the last message in a room ignorin unverified devices.", + # Synopsis + "", + # Description + "Send the last message in a room despite there being unverified " + "devices. The unverified devices will be marked as ignored after " + "running this command.", + # Completions + "", + # Callback + "matrix_send_anyways_cb", + "", + ) + + W.hook_command_run("/buffer clear", "matrix_command_buf_clear_cb", "") + + if G.CONFIG.network.fetch_backlog_on_pgup: + hook_page_up() + + +def format_device(device_id, fp_key, display_name): + fp_key = partition_key(fp_key) + message = (" - Device ID: {device_color}{device_id}{ncolor}\n" + " - Display name: {device_color}{display_name}{ncolor}\n" + " - Device key: {key_color}{fp_key}{ncolor}").format( + device_color=W.color("chat_channel"), + device_id=device_id, + ncolor=W.color("reset"), + display_name=display_name, + key_color=W.color("chat_server"), + fp_key=fp_key) + return message + + +def olm_info_command(server, args): + def print_devices( + device_store, + filter_regex, + device_category="All", + predicate=None, + ): + user_strings = [] + try: + filter_regex = re.compile(args.filter) if args.filter else None + except re.error as e: + server.error("Invalid regular expression: {}.".format(e.args[0])) + return + + for user_id in sorted(device_store.users): + device_strings = [] + for device in device_store.active_user_devices(user_id): + if filter_regex: + if (not filter_regex.search(user_id) and + not filter_regex.search(device.id)): + continue + + if predicate: + if not predicate(device): + continue + + device_strings.append(format_device( + device.id, + device.ed25519, + device.display_name + )) + + if not device_strings: + continue + + d_string = "\n".join(device_strings) + message = (" - User: {user_color}{user}{ncolor}\n").format( + user_color=W.color("chat_nick"), + user=user_id, + ncolor=W.color("reset")) + message += d_string + user_strings.append(message) + + if not user_strings: + message = ("{prefix}matrix: No matching devices " + "found.").format(prefix=W.prefix("error")) + W.prnt(server.server_buffer, message) + return + + server.info("{} devices:\n".format(device_category)) + W.prnt(server.server_buffer, "\n".join(user_strings)) + + olm = server.client.olm + + if args.category == "private": + fp_key = partition_key(olm.account.identity_keys["ed25519"]) + message = ("Identity keys:\n" + " - User: {user_color}{user}{ncolor}\n" + " - Device ID: {device_color}{device_id}{ncolor}\n" + " - Device key: {key_color}{fp_key}{ncolor}\n" + "").format( + user_color=W.color("chat_self"), + ncolor=W.color("reset"), + user=olm.user_id, + device_color=W.color("chat_channel"), + device_id=olm.device_id, + key_color=W.color("chat_server"), + fp_key=fp_key) + server.info(message) + + elif args.category == "all": + print_devices(olm.device_store, args.filter) + + elif args.category == "verified": + print_devices( + olm.device_store, + args.filter, + "Verified", + olm.is_device_verified + ) + + elif args.category == "unverified": + def predicate(device): + return not olm.is_device_verified(device) + + print_devices( + olm.device_store, + args.filter, + "Unverified", + predicate + ) + + elif args.category == "blacklisted": + print_devices( + olm.device_store, + args.filter, + "Blacklisted", + olm.is_device_blacklisted + ) + + elif args.category == "ignored": + print_devices( + olm.device_store, + args.filter, + "Ignored", + olm.is_device_ignored + ) + + +def olm_action_command(server, args, category, error_category, prefix, action): + device_store = server.client.olm.device_store + users = [] + + if args.user_filter == "*": + users = device_store.users + else: + users = [x for x in device_store.users if args.user_filter in x] + + user_devices = { + user: device_store.active_user_devices(user) for user in users + } + + if args.device_filter and args.device_filter != "*": + filtered_user_devices = {} + for user, device_list in user_devices.items(): + filtered_devices = filter( + lambda x: args.device_filter in x.id, + device_list + ) + filtered_user_devices[user] = list(filtered_devices) + user_devices = filtered_user_devices + + changed_devices = defaultdict(list) + + for user, device_list in user_devices.items(): + for device in device_list: + if action(device): + changed_devices[user].append(device) + + if not changed_devices: + message = ("{prefix}matrix: No matching {error_category} devices " + "found.").format( + prefix=W.prefix("error"), + error_category=error_category + ) + W.prnt(server.server_buffer, message) + return + + user_strings = [] + for user_id, device_list in changed_devices.items(): + device_strings = [] + message = (" - User: {user_color}{user}{ncolor}\n").format( + user_color=W.color("chat_nick"), + user=user_id, + ncolor=W.color("reset")) + for device in device_list: + device_strings.append(format_device( + device.id, + device.ed25519, + device.display_name + )) + if not device_strings: + continue + + d_string = "\n".join(device_strings) + message += d_string + user_strings.append(message) + + W.prnt(server.server_buffer, + "{}matrix: {} key(s):\n".format(W.prefix("prefix"), category)) + W.prnt(server.server_buffer, "\n".join(user_strings)) + pass + + +def olm_verify_command(server, args): + olm_action_command( + server, + args, + "Verified", + "unverified", + "join", + server.client.verify_device + ) + + +def olm_unverify_command(server, args): + olm_action_command( + server, + args, + "Unverified", + "verified", + "quit", + server.client.unverify_device + ) + + +def olm_blacklist_command(server, args): + olm_action_command( + server, + args, + "Blacklisted", + "unblacklisted", + "join", + server.client.blacklist_device + ) + + +def olm_unblacklist_command(server, args): + olm_action_command( + server, + args, + "Unblacklisted", + "blacklisted", + "join", + server.client.unblacklist_device + ) + + +def olm_ignore_command(server, args): + olm_action_command( + server, + args, + "Ignored", + "ignored", + "join", + server.client.ignore_device + ) + + +def olm_unignore_command(server, args): + olm_action_command( + server, + args, + "Unignored", + "unignored", + "join", + server.client.unignore_device + ) + + +def olm_export_command(server, args): + file_path = os.path.expanduser(args.file) + try: + server.client.export_keys(file_path, args.passphrase) + except (OSError, IOError) as e: + server.error("Error exporting keys: {}".format(str(e))) + + server.info("Succesfully exported keys") + +def olm_import_command(server, args): + file_path = os.path.expanduser(args.file) + try: + server.client.import_keys(file_path, args.passphrase) + except (OSError, IOError, EncryptionError) as e: + server.error("Error importing keys: {}".format(str(e))) + + server.info("Succesfully imported keys") + + +def olm_sas_command(server, args): + try: + device_store = server.client.device_store + except LocalProtocolError: + server.error("The device store is not loaded") + return W.WEECHAT_RC_OK + + try: + device = device_store[args.user_id][args.device_id] + except KeyError: + server.error("Device {} of user {} not found".format( + args.user_id, + args.device_id + )) + return W.WEECHAT_RC_OK + + if device.deleted: + server.error("Device {} of user {} is deleted.".format( + args.user_id, + args.device_id + )) + return W.WEECHAT_RC_OK + + if args.action == "start": + server.start_verification(device) + elif args.action in ["accept", "confirm", "cancel"]: + sas = server.client.get_active_sas(args.user_id, args.device_id) + + if not sas: + server.error("No active key verification found for " + "device {} of user {}.".format( + args.device_id, + args.user_id + )) + return W.WEECHAT_RC_OK + + try: + if args.action == "accept": + server.accept_sas(sas) + elif args.action == "confirm": + server.confirm_sas(sas) + elif args.action == "cancel": + server.cancel_sas(sas) + + except LocalProtocolError as e: + server.error(str(e)) + + +@utf8_decode +def matrix_olm_command_cb(data, buffer, args): + def command(server, data, buffer, args): + parsed_args = WeechatCommandParser.olm(args) + if not parsed_args: + return W.WEECHAT_RC_OK + + if not server.client.olm: + W.prnt(server.server_buffer, "{}matrix: Olm account isn't " + "loaded.".format(W.prefix("error"))) + return W.WEECHAT_RC_OK + + if not parsed_args.subcommand or parsed_args.subcommand == "info": + olm_info_command(server, parsed_args) + elif parsed_args.subcommand == "export": + olm_export_command(server, parsed_args) + elif parsed_args.subcommand == "import": + olm_import_command(server, parsed_args) + elif parsed_args.subcommand == "verify": + olm_verify_command(server, parsed_args) + elif parsed_args.subcommand == "unverify": + olm_unverify_command(server, parsed_args) + elif parsed_args.subcommand == "blacklist": + olm_blacklist_command(server, parsed_args) + elif parsed_args.subcommand == "unblacklist": + olm_unblacklist_command(server, parsed_args) + elif parsed_args.subcommand == "verification": + olm_sas_command(server, parsed_args) + elif parsed_args.subcommand == "ignore": + olm_ignore_command(server, parsed_args) + elif parsed_args.subcommand == "unignore": + olm_unignore_command(server, parsed_args) + else: + message = ("{prefix}matrix: Command not implemented.".format( + prefix=W.prefix("error"))) + W.prnt(server.server_buffer, message) + + W.bar_item_update("buffer_modes") + W.bar_item_update("matrix_modes") + + return W.WEECHAT_RC_OK + + for server in SERVERS.values(): + if buffer in server.buffers.values(): + return command(server, data, buffer, args) + elif buffer == server.server_buffer: + return command(server, data, buffer, args) + + W.prnt("", "{prefix}matrix: command \"olm\" must be executed on a " + "matrix buffer (server or channel)".format( + prefix=W.prefix("error") + )) + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_devices_command_cb(data, buffer, args): + for server in SERVERS.values(): + if buffer in server.buffers.values() or buffer == server.server_buffer: + parsed_args = WeechatCommandParser.devices(args) + if not parsed_args: + return W.WEECHAT_RC_OK + + if not parsed_args.subcommand or parsed_args.subcommand == "list": + server.devices() + elif parsed_args.subcommand == "delete": + server.delete_device(parsed_args.device_id) + elif parsed_args.subcommand == "set-name": + new_name = " ".join(parsed_args.device_name).strip("\"") + server.rename_device(parsed_args.device_id, new_name) + + return W.WEECHAT_RC_OK + + W.prnt("", "{prefix}matrix: command \"devices\" must be executed on a " + "matrix buffer (server or channel)".format( + prefix=W.prefix("error") + )) + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_me_command_cb(data, buffer, args): + for server in SERVERS.values(): + if buffer in server.buffers.values(): + + if not server.connected: + message = ( + "{prefix}matrix: you are not connected to " "the server" + ).format(prefix=W.prefix("error")) + W.prnt(server.server_buffer, message) + return W.WEECHAT_RC_ERROR + + room_buffer = server.find_room_from_ptr(buffer) + + if not args: + return W.WEECHAT_RC_OK + + formatted_data = Formatted.from_input_line(args) + + server.room_send_message(room_buffer, formatted_data, "m.emote") + return W.WEECHAT_RC_OK + + if buffer == server.server_buffer: + message = ( + '{prefix}matrix: command "me" must be ' + "executed on a Matrix channel buffer" + ).format(prefix=W.prefix("error")) + W.prnt("", message) + return W.WEECHAT_RC_OK + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_topic_command_cb(data, buffer, args): + parsed_args = WeechatCommandParser.topic(args) + if not parsed_args: + return W.WEECHAT_RC_OK + + for server in SERVERS.values(): + if buffer == server.server_buffer: + server.error( + 'command "topic" must be ' "executed on a Matrix room buffer" + ) + return W.WEECHAT_RC_OK + + room = server.find_room_from_ptr(buffer) + if not room: + continue + + if not parsed_args.topic and not parsed_args.delete: + # TODO print the current topic + return W.WEECHAT_RC_OK + + if parsed_args.delete and parsed_args.topic: + # TODO error message + return W.WEECHAT_RC_OK + + topic = "" if parsed_args.delete else " ".join(parsed_args.topic) + content = {"topic": topic} + server.room_send_state(room, content, "m.room.topic") + + return W.WEECHAT_RC_OK + + +def matrix_fetch_old_messages(server, room_id): + room_buffer = server.find_room_from_id(room_id) + room = room_buffer.room + + if room_buffer.backlog_pending: + return + + prev_batch = room.prev_batch + + if not prev_batch: + return + + raise NotImplementedError + + +def check_server_existence(server_name, servers): + if server_name not in servers: + message = "{prefix}matrix: No such server: {server}".format( + prefix=W.prefix("error"), server=server_name + ) + W.prnt("", message) + return False + return True + + +def hook_page_up(): + G.CONFIG.page_up_hook = W.hook_command_run( + "/window page_up", "matrix_command_pgup_cb", "" + ) + + +@utf8_decode +def matrix_command_buf_clear_cb(data, buffer, command): + for server in SERVERS.values(): + if buffer in server.buffers.values(): + room_buffer = server.find_room_from_ptr(buffer) + room_buffer.room.prev_batch = server.next_batch + + return W.WEECHAT_RC_OK + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_command_pgup_cb(data, buffer, command): + # TODO the highlight status of a line isn't allowed to be updated/changed + # via hdata, therefore the highlight status of a messages can't be + # reoredered this would need to be fixed in weechat + # TODO we shouldn't fetch and print out more messages than + # max_buffer_lines_number or older messages than max_buffer_lines_minutes + for server in SERVERS.values(): + if buffer in server.buffers.values(): + window = W.window_search_with_buffer(buffer) + + first_line_displayed = bool( + W.window_get_integer(window, "first_line_displayed") + ) + + if first_line_displayed: + room_id = key_from_value(server.buffers, buffer) + server.room_get_messages(room_id) + + return W.WEECHAT_RC_OK + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_join_command_cb(data, buffer, args): + parsed_args = WeechatCommandParser.join(args) + if not parsed_args: + return W.WEECHAT_RC_OK + + for server in SERVERS.values(): + if buffer in server.buffers.values() or buffer == server.server_buffer: + server.room_join(parsed_args.room_id) + break + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_part_command_cb(data, buffer, args): + parsed_args = WeechatCommandParser.part(args) + if not parsed_args: + return W.WEECHAT_RC_OK + + for server in SERVERS.values(): + if buffer in server.buffers.values() or buffer == server.server_buffer: + room_id = parsed_args.room_id + + if not room_id: + if buffer == server.server_buffer: + server.error( + 'command "part" must be ' + "executed on a Matrix room buffer or a room " + "name needs to be given" + ) + return W.WEECHAT_RC_OK + + room_buffer = server.find_room_from_ptr(buffer) + room_id = room_buffer.room.room_id + + server.room_leave(room_id) + break + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_invite_command_cb(data, buffer, args): + parsed_args = WeechatCommandParser.invite(args) + if not parsed_args: + return W.WEECHAT_RC_OK + + for server in SERVERS.values(): + if buffer == server.server_buffer: + server.error( + 'command "invite" must be ' "executed on a Matrix room buffer" + ) + return W.WEECHAT_RC_OK + + room = server.find_room_from_ptr(buffer) + if not room: + continue + + user_id = parsed_args.user_id + user_id = user_id if user_id.startswith("@") else "@" + user_id + + server.room_invite(room, user_id) + break + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_room_command_cb(data, buffer, args): + parsed_args = WeechatCommandParser.room(args) + if not parsed_args: + return W.WEECHAT_RC_OK + + for server in SERVERS.values(): + if buffer == server.server_buffer: + server.error( + 'command "room" must be ' "executed on a Matrix room buffer" + ) + return W.WEECHAT_RC_OK + + room = server.find_room_from_ptr(buffer) + if not room: + continue + + if not parsed_args.subcommand or parsed_args.subcommand == "list": + server.error("command no subcommand found") + return W.WEECHAT_RC_OK + + if parsed_args.subcommand == "typing-notifications": + if parsed_args.state == "enable": + room.typing_enabled = True + elif parsed_args.state == "disable": + room.typing_enabled = False + elif parsed_args.state == "toggle": + room.typing_enabled = not room.typing_enabled + break + + elif parsed_args.subcommand == "read-markers": + if parsed_args.state == "enable": + room.read_markers_enabled = True + elif parsed_args.state == "disable": + room.read_markers_enabled = False + elif parsed_args.state == "toggle": + room.read_markers_enabled = not room.read_markers_enabled + break + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_uploads_command_cb(data, buffer, args): + if not args: + if not G.CONFIG.upload_buffer: + G.CONFIG.upload_buffer = UploadsBuffer() + G.CONFIG.upload_buffer.display() + return W.WEECHAT_RC_OK + + parsed_args = WeechatCommandParser.uploads(args) + if not parsed_args: + return W.WEECHAT_RC_OK + + if parsed_args.subcommand == "list": + pass + elif parsed_args.subcommand == "listfull": + pass + elif parsed_args.subcommand == "up": + if G.CONFIG.upload_buffer: + G.CONFIG.upload_buffer.move_line_up() + elif parsed_args.subcommand == "down": + if G.CONFIG.upload_buffer: + G.CONFIG.upload_buffer.move_line_down() + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_upload_command_cb(data, buffer, args): + parsed_args = WeechatCommandParser.upload(args) + if not parsed_args: + return W.WEECHAT_RC_OK + + for server in SERVERS.values(): + if buffer == server.server_buffer: + server.error( + 'command "upload" must be ' "executed on a Matrix room buffer" + ) + return W.WEECHAT_RC_OK + + room_buffer = server.find_room_from_ptr(buffer) + if not room_buffer: + continue + + upload = Upload( + server.name, + server.config.address, + server.client.access_token, + room_buffer.room.room_id, + parsed_args.file, + room_buffer.room.encrypted + ) + UPLOADS[upload.uuid] = upload + + if G.CONFIG.upload_buffer: + G.CONFIG.upload_buffer.render() + + break + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_kick_command_cb(data, buffer, args): + parsed_args = WeechatCommandParser.kick(args) + if not parsed_args: + return W.WEECHAT_RC_OK + + for server in SERVERS.values(): + if buffer == server.server_buffer: + server.error( + 'command "kick" must be ' "executed on a Matrix room buffer" + ) + return W.WEECHAT_RC_OK + + room = server.find_room_from_ptr(buffer) + if not room: + continue + + user_id = parsed_args.user_id + user_id = user_id if user_id.startswith("@") else "@" + user_id + reason = " ".join(parsed_args.reason) if parsed_args.reason else None + + server.room_kick(room, user_id, reason) + break + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_redact_command_cb(data, buffer, args): + def already_redacted(line): + if SCRIPT_NAME + "_redacted" in line.tags: + return True + return False + + def predicate(event_id, line): + event_tag = SCRIPT_NAME + "_id_{}".format(event_id) + tags = line.tags + + if event_tag in tags: + return True + + return False + + for server in SERVERS.values(): + if buffer in server.buffers.values(): + room_buffer = server.find_room_from_ptr(buffer) + + matches = re.match( + r"^(\$[a-zA-Z0-9]+:([a-z0-9])(([a-z0-9-]{1,61})?[a-z0-9]{1})?" + r"(\.[a-z0-9](([a-z0-9-]{1,61})?[a-z0-9]{1})?)?" + r"(\.[a-zA-Z]{2,4})+)(:\".*\")? ?(.*)?$", + args + ) + + if not matches: + message = ( + "{prefix}matrix: Invalid command " + "arguments (see /help redact)" + ).format(prefix=W.prefix("error")) + W.prnt("", message) + return W.WEECHAT_RC_ERROR + + groups = matches.groups() + event_id, reason = (groups[0], groups[-1]) + + lines = room_buffer.weechat_buffer.find_lines( + partial(predicate, event_id), max_lines=1 + ) + + if not lines: + room_buffer.error( + "No such message with event id " + "{event_id} found.".format(event_id=event_id)) + return W.WEECHAT_RC_OK + + if already_redacted(lines[0]): + room_buffer.error("Event already redacted.") + return W.WEECHAT_RC_OK + + server.room_send_redaction(room_buffer, event_id, reason) + + return W.WEECHAT_RC_OK + + if buffer == server.server_buffer: + message = ( + '{prefix}matrix: command "redact" must be ' + "executed on a Matrix channel buffer" + ).format(prefix=W.prefix("error")) + W.prnt("", message) + return W.WEECHAT_RC_OK + + return W.WEECHAT_RC_OK + + +def matrix_command_help(args): + if not args: + message = ( + "{prefix}matrix: Too few arguments for command " + '"/matrix help" (see /matrix help help)' + ).format(prefix=W.prefix("error")) + W.prnt("", message) + return + + for command in args: + message = "" + + if command == "connect": + message = ( + "{delimiter_color}[{ncolor}matrix{delimiter_color}] " + "{ncolor}{cmd_color}/connect{ncolor} " + " [...]" + "\n\n" + "connect to Matrix server(s)" + "\n\n" + "server-name: server to connect to" + "(internal name)" + ).format( + delimiter_color=W.color("chat_delimiters"), + cmd_color=W.color("chat_buffer"), + ncolor=W.color("reset"), + ) + + elif command == "disconnect": + message = ( + "{delimiter_color}[{ncolor}matrix{delimiter_color}] " + "{ncolor}{cmd_color}/disconnect{ncolor} " + " [...]" + "\n\n" + "disconnect from Matrix server(s)" + "\n\n" + "server-name: server to disconnect" + "(internal name)" + ).format( + delimiter_color=W.color("chat_delimiters"), + cmd_color=W.color("chat_buffer"), + ncolor=W.color("reset"), + ) + + elif command == "reconnect": + message = ( + "{delimiter_color}[{ncolor}matrix{delimiter_color}] " + "{ncolor}{cmd_color}/reconnect{ncolor} " + " [...]" + "\n\n" + "reconnect to Matrix server(s)" + "\n\n" + "server-name: server to reconnect" + "(internal name)" + ).format( + delimiter_color=W.color("chat_delimiters"), + cmd_color=W.color("chat_buffer"), + ncolor=W.color("reset"), + ) + + elif command == "server": + message = ( + "{delimiter_color}[{ncolor}matrix{delimiter_color}] " + "{ncolor}{cmd_color}/server{ncolor} " + "add [:]" + "\n " + "delete|list|listfull " + "\n\n" + "list, add, or remove Matrix servers" + "\n\n" + " list: list servers (without argument, this " + "list is displayed)\n" + " listfull: list servers with detailed info for each " + "server\n" + " add: add a new server\n" + " delete: delete a server\n" + "server-name: server to reconnect (internal name)\n" + " hostname: name or IP address of server\n" + " port: port of server (default: 8448)\n" + "\n" + "Examples:" + "\n /matrix server listfull" + "\n /matrix server add matrix matrix.org:80" + "\n /matrix server delete matrix" + ).format( + delimiter_color=W.color("chat_delimiters"), + cmd_color=W.color("chat_buffer"), + ncolor=W.color("reset"), + ) + + elif command == "help": + message = ( + "{delimiter_color}[{ncolor}matrix{delimiter_color}] " + "{ncolor}{cmd_color}/help{ncolor} " + " [...]" + "\n\n" + "display help about Matrix commands" + "\n\n" + "matrix-command: a Matrix command name" + "(internal name)" + ).format( + delimiter_color=W.color("chat_delimiters"), + cmd_color=W.color("chat_buffer"), + ncolor=W.color("reset"), + ) + + else: + message = ( + '{prefix}matrix: No help available, "{command}" ' + "is not a matrix command" + ).format(prefix=W.prefix("error"), command=command) + + W.prnt("", "") + W.prnt("", message) + + return + + +def matrix_server_command_listfull(args): + def get_value_string(value, default_value): + if value == default_value: + if not value: + value = "''" + value_string = " ({value})".format(value=value) + else: + value_string = "{color}{value}{ncolor}".format( + color=W.color("chat_value"), + value=value, + ncolor=W.color("reset"), + ) + + return value_string + + for server_name in args: + if server_name not in SERVERS: + continue + + server = SERVERS[server_name] + connected = "" + + W.prnt("", "") + + if server.connected: + connected = "connected" + else: + connected = "not connected" + + message = ( + "Server: {server_color}{server}{delimiter_color}" + " [{ncolor}{connected}{delimiter_color}]" + "{ncolor}" + ).format( + server_color=W.color("chat_server"), + server=server.name, + delimiter_color=W.color("chat_delimiters"), + connected=connected, + ncolor=W.color("reset"), + ) + + W.prnt("", message) + + option = server.config._option_ptrs["autoconnect"] + default_value = W.config_string_default(option) + value = W.config_string(option) + + value_string = get_value_string(value, default_value) + message = " autoconnect. : {value}".format(value=value_string) + + W.prnt("", message) + + option = server.config._option_ptrs["address"] + default_value = W.config_string_default(option) + value = W.config_string(option) + + value_string = get_value_string(value, default_value) + message = " address. . . : {value}".format(value=value_string) + + W.prnt("", message) + + option = server.config._option_ptrs["port"] + default_value = str(W.config_integer_default(option)) + value = str(W.config_integer(option)) + + value_string = get_value_string(value, default_value) + message = " port . . . . : {value}".format(value=value_string) + + W.prnt("", message) + + option = server.config._option_ptrs["username"] + default_value = W.config_string_default(option) + value = W.config_string(option) + + value_string = get_value_string(value, default_value) + message = " username . . : {value}".format(value=value_string) + + W.prnt("", message) + + option = server.config._option_ptrs["password"] + value = W.config_string(option) + + if value: + value = "(hidden)" + + value_string = get_value_string(value, "") + message = " password . . : {value}".format(value=value_string) + + W.prnt("", message) + + +def matrix_server_command_delete(args): + for server_name in args: + if check_server_existence(server_name, SERVERS): + server = SERVERS[server_name] + + if server.connected: + message = ( + "{prefix}matrix: you can not delete server " + "{color}{server}{ncolor} because you are " + 'connected to it. Try "/matrix disconnect ' + '{color}{server}{ncolor}" before.' + ).format( + prefix=W.prefix("error"), + color=W.color("chat_server"), + ncolor=W.color("reset"), + server=server.name, + ) + W.prnt("", message) + return + + for buf in server.buffers.values(): + W.buffer_close(buf) + + if server.server_buffer: + W.buffer_close(server.server_buffer) + + for option in server.config._option_ptrs.values(): + W.config_option_free(option) + + message = ( + "matrix: server {color}{server}{ncolor} has been " "deleted" + ).format( + server=server.name, + color=W.color("chat_server"), + ncolor=W.color("reset"), + ) + + del SERVERS[server.name] + server = None + + W.prnt("", message) + + +def matrix_server_command_add(args): + if len(args) < 2: + message = ( + "{prefix}matrix: Too few arguments for command " + '"/matrix server add" (see /matrix help server)' + ).format(prefix=W.prefix("error")) + W.prnt("", message) + return + if len(args) > 4: + message = ( + "{prefix}matrix: Too many arguments for command " + '"/matrix server add" (see /matrix help server)' + ).format(prefix=W.prefix("error")) + W.prnt("", message) + return + + def remove_server(server): + for option in server.config._option_ptrs.values(): + W.config_option_free(option) + del SERVERS[server.name] + + server_name = args[0] + + if server_name in SERVERS: + message = ( + "{prefix}matrix: server {color}{server}{ncolor} " + "already exists, can't add it" + ).format( + prefix=W.prefix("error"), + color=W.color("chat_server"), + server=server_name, + ncolor=W.color("reset"), + ) + W.prnt("", message) + return + + server = MatrixServer(server_name, G.CONFIG._ptr) + SERVERS[server.name] = server + + if len(args) >= 2: + try: + host, port = args[1].split(":", 1) + except ValueError: + host, port = args[1], None + + return_code = W.config_option_set( + server.config._option_ptrs["address"], host, 1 + ) + + if return_code == W.WEECHAT_CONFIG_OPTION_SET_ERROR: + remove_server(server) + message = ( + "{prefix}Failed to set address for server " + "{color}{server}{ncolor}, failed to add " + "server." + ).format( + prefix=W.prefix("error"), + color=W.color("chat_server"), + server=server.name, + ncolor=W.color("reset"), + ) + + W.prnt("", message) + server = None + return + + if port: + return_code = W.config_option_set( + server.config._option_ptrs["port"], port, 1 + ) + if return_code == W.WEECHAT_CONFIG_OPTION_SET_ERROR: + remove_server(server) + message = ( + "{prefix}Failed to set port for server " + "{color}{server}{ncolor}, failed to add " + "server." + ).format( + prefix=W.prefix("error"), + color=W.color("chat_server"), + server=server.name, + ncolor=W.color("reset"), + ) + + W.prnt("", message) + server = None + return + + if len(args) >= 3: + user = args[2] + return_code = W.config_option_set( + server.config._option_ptrs["username"], user, 1 + ) + + if return_code == W.WEECHAT_CONFIG_OPTION_SET_ERROR: + remove_server(server) + message = ( + "{prefix}Failed to set user for server " + "{color}{server}{ncolor}, failed to add " + "server." + ).format( + prefix=W.prefix("error"), + color=W.color("chat_server"), + server=server.name, + ncolor=W.color("reset"), + ) + + W.prnt("", message) + server = None + return + + if len(args) == 4: + password = args[3] + + return_code = W.config_option_set( + server.config._option_ptrs["password"], password, 1 + ) + if return_code == W.WEECHAT_CONFIG_OPTION_SET_ERROR: + remove_server(server) + message = ( + "{prefix}Failed to set password for server " + "{color}{server}{ncolor}, failed to add " + "server." + ).format( + prefix=W.prefix("error"), + color=W.color("chat_server"), + server=server.name, + ncolor=W.color("reset"), + ) + W.prnt("", message) + server = None + return + + message = ( + "matrix: server {color}{server}{ncolor} " "has been added" + ).format( + server=server.name, + color=W.color("chat_server"), + ncolor=W.color("reset"), + ) + W.prnt("", message) + + +def matrix_server_command(command, args): + def list_servers(_): + if SERVERS: + W.prnt("", "\nAll matrix servers:") + for server in SERVERS: + W.prnt( + "", + " {color}{server}".format( + color=W.color("chat_server"), server=server + ), + ) + + # TODO the argument for list and listfull is used as a match word to + # find/filter servers, we're currently match exactly to the whole name + if command == "list": + list_servers(args) + elif command == "listfull": + matrix_server_command_listfull(args) + elif command == "add": + matrix_server_command_add(args) + elif command == "delete": + matrix_server_command_delete(args) + else: + message = ( + "{prefix}matrix: Error: unknown matrix server command, " + '"{command}" (type /matrix help server for help)' + ).format(prefix=W.prefix("error"), command=command) + W.prnt("", message) + + +@utf8_decode +def matrix_command_cb(data, buffer, args): + def connect_server(args): + for server_name in args: + if check_server_existence(server_name, SERVERS): + server = SERVERS[server_name] + server.connect() + + def disconnect_server(args): + for server_name in args: + if check_server_existence(server_name, SERVERS): + server = SERVERS[server_name] + if server.connected or server.reconnect_time: + # W.unhook(server.timer_hook) + # server.timer_hook = None + server.access_token = "" + server.disconnect(reconnect=False) + + split_args = list(filter(bool, args.split(" "))) + + if len(split_args) < 1: + message = ( + "{prefix}matrix: Too few arguments for command " + '"/matrix" ' + "(see /help matrix)" + ).format(prefix=W.prefix("error")) + W.prnt("", message) + return W.WEECHAT_RC_ERROR + + command, args = split_args[0], split_args[1:] + + if command == "connect": + connect_server(args) + + elif command == "disconnect": + disconnect_server(args) + + elif command == "reconnect": + disconnect_server(args) + connect_server(args) + + elif command == "server": + if len(args) >= 1: + subcommand, args = args[0], args[1:] + matrix_server_command(subcommand, args) + else: + matrix_server_command("list", "") + + elif command == "help": + matrix_command_help(args) + + else: + message = ( + "{prefix}matrix: Error: unknown matrix command, " + '"{command}" (type /help matrix for help)' + ).format(prefix=W.prefix("error"), command=command) + W.prnt("", message) + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_send_anyways_cb(data, buffer, args): + for server in SERVERS.values(): + if buffer in server.buffers.values(): + room_buffer = server.find_room_from_ptr(buffer) + + if not server.connected: + room_buffer.error("Server is diconnected") + break + + if not room_buffer.last_message: + room_buffer.error("No previously sent message found.") + break + + server.room_send_message( + room_buffer, + room_buffer.last_message, + "m.text", + ignore_unverified_devices=True + ) + room_buffer.last_message = None + + break + else: + message = ( + "{prefix}matrix: The 'send-anyways' command needs to be " + "run on a matrix room buffer" + ).format(prefix=W.prefix("error")) + W.prnt("", message) + + return W.WEECHAT_RC_ERROR diff --git a/.weechat/python/matrix/completion.py b/.weechat/python/matrix/completion.py new file mode 100644 index 0000000..578309e --- /dev/null +++ b/.weechat/python/matrix/completion.py @@ -0,0 +1,366 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2018, 2019 Damir Jelić +# +# Permission to use, copy, modify, and/or distribute this software for +# any purpose with or without fee is hereby granted, provided that the +# above copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER +# RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from __future__ import unicode_literals + +from typing import List, Optional +from matrix.globals import SERVERS, W, SCRIPT_NAME +from matrix.utf import utf8_decode +from matrix.utils import tags_from_line_data +from nio import LocalProtocolError + + +def add_servers_to_completion(completion): + for server_name in SERVERS: + W.hook_completion_list_add( + completion, server_name, 0, W.WEECHAT_LIST_POS_SORT + ) + + +@utf8_decode +def matrix_server_command_completion_cb( + data, completion_item, buffer, completion +): + buffer_input = W.buffer_get_string(buffer, "input").split() + + args = buffer_input[1:] + commands = ["add", "delete", "list", "listfull"] + + def complete_commands(): + for command in commands: + W.hook_completion_list_add( + completion, command, 0, W.WEECHAT_LIST_POS_SORT + ) + + if len(args) == 1: + complete_commands() + + elif len(args) == 2: + if args[1] not in commands: + complete_commands() + else: + if args[1] == "delete" or args[1] == "listfull": + add_servers_to_completion(completion) + + elif len(args) == 3: + if args[1] == "delete" or args[1] == "listfull": + if args[2] not in SERVERS: + add_servers_to_completion(completion) + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_server_completion_cb(data, completion_item, buffer, completion): + add_servers_to_completion(completion) + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_command_completion_cb(data, completion_item, buffer, completion): + for command in [ + "connect", + "disconnect", + "reconnect", + "server", + "help", + "debug", + ]: + W.hook_completion_list_add( + completion, command, 0, W.WEECHAT_LIST_POS_SORT + ) + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_debug_completion_cb(data, completion_item, buffer, completion): + for debug_type in ["messaging", "network", "timing"]: + W.hook_completion_list_add( + completion, debug_type, 0, W.WEECHAT_LIST_POS_SORT + ) + return W.WEECHAT_RC_OK + + +# TODO this should be configurable +REDACTION_COMP_LEN = 50 + + +@utf8_decode +def matrix_message_completion_cb(data, completion_item, buffer, completion): + max_events = 500 + + def redacted_or_not_message(tags): + # type: (List[str]) -> bool + if SCRIPT_NAME + "_redacted" in tags: + return True + if SCRIPT_NAME + "_message" not in tags: + return True + + return False + + def event_id_from_tags(tags): + # type: (List[str]) -> Optional[str] + for tag in tags: + if tag.startswith("matrix_id"): + event_id = tag[10:] + return event_id + + return None + + for server in SERVERS.values(): + if buffer in server.buffers.values(): + room_buffer = server.find_room_from_ptr(buffer) + lines = room_buffer.weechat_buffer.lines + + added = 0 + + for line in lines: + tags = line.tags + if redacted_or_not_message(tags): + continue + + event_id = event_id_from_tags(tags) + + if not event_id: + continue + + message = line.message + + if len(message) > REDACTION_COMP_LEN + 2: + message = message[:REDACTION_COMP_LEN] + ".." + + item = ('{event_id}:"{message}"').format( + event_id=event_id, message=message + ) + + W.hook_completion_list_add( + completion, item, 0, W.WEECHAT_LIST_POS_END + ) + added += 1 + + if added >= max_events: + break + + return W.WEECHAT_RC_OK + + return W.WEECHAT_RC_OK + + +def server_from_buffer(buffer): + for server in SERVERS.values(): + if buffer in server.buffers.values(): + return server + if buffer == server.server_buffer: + return server + return None + + +@utf8_decode +def matrix_olm_user_completion_cb(data, completion_item, buffer, completion): + server = server_from_buffer(buffer) + + if not server: + return W.WEECHAT_RC_OK + + try: + device_store = server.client.device_store + except LocalProtocolError: + return W.WEECHAT_RC_OK + + for user in device_store.users: + W.hook_completion_list_add( + completion, user, 0, W.WEECHAT_LIST_POS_SORT + ) + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_olm_device_completion_cb(data, completion_item, buffer, completion): + server = server_from_buffer(buffer) + + if not server: + return W.WEECHAT_RC_OK + + try: + device_store = server.client.device_store + except LocalProtocolError: + return W.WEECHAT_RC_OK + + args = W.hook_completion_get_string(completion, "args") + + fields = args.split() + + if len(fields) < 2: + return W.WEECHAT_RC_OK + + user = fields[-1] + + if user not in device_store.users: + return W.WEECHAT_RC_OK + + for device in device_store.active_user_devices(user): + W.hook_completion_list_add( + completion, device.id, 0, W.WEECHAT_LIST_POS_SORT + ) + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_own_devices_completion_cb( + data, + completion_item, + buffer, + completion +): + server = server_from_buffer(buffer) + + if not server: + return W.WEECHAT_RC_OK + + olm = server.client.olm + + if not olm: + return W.WEECHAT_RC_OK + + W.hook_completion_list_add( + completion, olm.device_id, 0, W.WEECHAT_LIST_POS_SORT + ) + + user = olm.user_id + + if user not in olm.device_store.users: + return W.WEECHAT_RC_OK + + for device in olm.device_store.active_user_devices(user): + W.hook_completion_list_add( + completion, device.id, 0, W.WEECHAT_LIST_POS_SORT + ) + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_user_completion_cb(data, completion_item, buffer, completion): + def add_user(completion, user): + W.hook_completion_list_add( + completion, user, 0, W.WEECHAT_LIST_POS_SORT + ) + + for server in SERVERS.values(): + if buffer == server.server_buffer: + return W.WEECHAT_RC_OK + + room_buffer = server.find_room_from_ptr(buffer) + + if not room_buffer: + continue + + users = room_buffer.room.users + + users = [user[1:] for user in users] + + for user in users: + add_user(completion, user) + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_room_completion_cb(data, completion_item, buffer, completion): + """Completion callback for matrix room names.""" + for server in SERVERS.values(): + for room_buffer in server.room_buffers.values(): + name = room_buffer.weechat_buffer.short_name + + W.hook_completion_list_add( + completion, name, 0, W.WEECHAT_LIST_POS_SORT + ) + + return W.WEECHAT_RC_OK + + +def init_completion(): + W.hook_completion( + "matrix_server_commands", + "Matrix server completion", + "matrix_server_command_completion_cb", + "", + ) + + W.hook_completion( + "matrix_servers", + "Matrix server completion", + "matrix_server_completion_cb", + "", + ) + + W.hook_completion( + "matrix_commands", + "Matrix command completion", + "matrix_command_completion_cb", + "", + ) + + W.hook_completion( + "matrix_messages", + "Matrix message completion", + "matrix_message_completion_cb", + "", + ) + + W.hook_completion( + "matrix_debug_types", + "Matrix debugging type completion", + "matrix_debug_completion_cb", + "", + ) + + W.hook_completion( + "olm_user_ids", + "Matrix olm user id completion", + "matrix_olm_user_completion_cb", + "", + ) + + W.hook_completion( + "olm_devices", + "Matrix olm device id completion", + "matrix_olm_device_completion_cb", + "", + ) + + W.hook_completion( + "matrix_users", + "Matrix user id completion", + "matrix_user_completion_cb", + "", + ) + + W.hook_completion( + "matrix_own_devices", + "Matrix own devices completion", + "matrix_own_devices_completion_cb", + "", + ) + + W.hook_completion( + "matrix_rooms", + "Matrix room name completion", + "matrix_room_completion_cb", + "", + ) diff --git a/.weechat/python/matrix/config.py b/.weechat/python/matrix/config.py new file mode 100644 index 0000000..82063d2 --- /dev/null +++ b/.weechat/python/matrix/config.py @@ -0,0 +1,807 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2018, 2019 Damir Jelić +# Copyright © 2018, 2019 Denis Kasak +# +# Permission to use, copy, modify, and/or distribute this software for +# any purpose with or without fee is hereby granted, provided that the +# above copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER +# RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +"""weechat-matrix Configuration module. + +This module contains abstractions on top of weechats configuration files and +the main script configuration class. + +To add configuration options refer to MatrixConfig. +Server specific configuration options are handled in server.py +""" + +from builtins import super +from collections import namedtuple +from enum import Enum, unique + +import logbook + +import nio +from matrix.globals import SCRIPT_NAME, SERVERS, W +from matrix.utf import utf8_decode + +from . import globals as G + + +@unique +class RedactType(Enum): + STRIKETHROUGH = 0 + NOTICE = 1 + DELETE = 2 + + +@unique +class ServerBufferType(Enum): + MERGE_CORE = 0 + MERGE = 1 + INDEPENDENT = 2 + + +nio.logger_group.level = logbook.ERROR + + +class Option( + namedtuple( + "Option", + [ + "name", + "type", + "string_values", + "min", + "max", + "value", + "description", + "cast_func", + "change_callback", + ], + ) +): + """A class representing a new configuration option. + + An option object is consumed by the ConfigSection class adding + configuration options to weechat. + """ + + __slots__ = () + + def __new__( + cls, + name, + type, + string_values, + min, + max, + value, + description, + cast=None, + change_callback=None, + ): + """ + Parameters: + name (str): Name of the configuration option + type (str): Type of the configuration option, can be one of the + supported weechat types: string, boolean, integer, color + string_values: (str): A list of string values that the option can + accept seprated by | + min (int): Minimal value of the option, only used if the type of + the option is integer + max (int): Maximal value of the option, only used if the type of + the option is integer + description (str): Description of the configuration option + cast (callable): A callable function taking a single value and + returning a modified value. Useful to turn the configuration + option into an enum while reading it. + change_callback(callable): A function that will be called + by weechat every time the configuration option is changed. + """ + + return super().__new__( + cls, + name, + type, + string_values, + min, + max, + value, + description, + cast, + change_callback, + ) + + +@utf8_decode +def matrix_config_reload_cb(data, config_file): + return W.WEECHAT_RC_OK + + +def change_log_level(category, level): + """Change the log level of the underlying nio lib + + Called every time the user changes the log level or log category + configuration option.""" + + if category == "all": + nio.logger_group.level = level + elif category == "http": + nio.http.logger.level = level + elif category == "client": + nio.client.logger.level = level + elif category == "events": + nio.events.logger.level = level + elif category == "responses": + nio.responses.logger.level = level + elif category == "encryption": + nio.crypto.logger.level = level + + +@utf8_decode +def config_server_buffer_cb(data, option): + """Callback for the look.server_buffer option. + Is called when the option is changed and merges/splits the server + buffer""" + + for server in SERVERS.values(): + server.buffer_merge() + return 1 + + +@utf8_decode +def config_log_level_cb(data, option): + """Callback for the network.debug_level option.""" + change_log_level( + G.CONFIG.network.debug_category, G.CONFIG.network.debug_level + ) + return 1 + + +@utf8_decode +def config_log_category_cb(data, option): + """Callback for the network.debug_category option.""" + change_log_level(G.CONFIG.debug_category, logbook.ERROR) + G.CONFIG.debug_category = G.CONFIG.network.debug_category + change_log_level( + G.CONFIG.network.debug_category, G.CONFIG.network.debug_level + ) + return 1 + + +@utf8_decode +def config_pgup_cb(data, option): + """Callback for the network.fetch_backlog_on_pgup option. + Enables or disables the hook that is run when /window page_up is called""" + if G.CONFIG.network.fetch_backlog_on_pgup: + if not G.CONFIG.page_up_hook: + G.CONFIG.page_up_hook = W.hook_command_run( + "/window page_up", "matrix_command_pgup_cb", "" + ) + else: + if G.CONFIG.page_up_hook: + W.unhook(G.CONFIG.page_up_hook) + G.CONFIG.page_up_hook = None + + return 1 + + +def level_to_logbook(value): + if value == 0: + return logbook.ERROR + if value == 1: + return logbook.WARNING + if value == 2: + return logbook.INFO + if value == 3: + return logbook.DEBUG + + return logbook.ERROR + + +def logbook_category(value): + if value == 0: + return "all" + if value == 1: + return "http" + if value == 2: + return "client" + if value == 3: + return "events" + if value == 4: + return "responses" + if value == 5: + return "encryption" + + return "all" + + +def eval_cast(string): + """A function that passes a string to weechat which evaluates it using its + expression evaluation syntax. + Can only be used with strings, useful for passwords or options that contain + a formatted string to e.g. add colors. + More info here: + https://weechat.org/files/doc/stable/weechat_plugin_api.en.html#_string_eval_expression""" + + return W.string_eval_expression(string, {}, {}, {}) + + +class WeechatConfig(object): + """A class representing a weechat configuration file + Wraps weechats configuration creation functionality""" + + def __init__(self, sections): + """Create a new weechat configuration file, expects the global + SCRIPT_NAME to be defined and a reload callback + + Parameters: + sections (List[Tuple[str, List[Option]]]): List of config sections + that will be created for the configuration file. + """ + self._ptr = W.config_new( + SCRIPT_NAME, SCRIPT_NAME + "_config_reload_cb", "" + ) + + for section in sections: + name, options = section + section_class = ConfigSection.build(name, options) + setattr(self, name, section_class(name, self._ptr, options)) + + def free(self): + """Free all the config sections and their options as well as the + configuration file. Should be called when the script is unloaded.""" + for section in [ + getattr(self, a) + for a in dir(self) + if isinstance(getattr(self, a), ConfigSection) + ]: + section.free() + + W.config_free(self._ptr) + + def read(self): + """Read the config file""" + return_code = W.config_read(self._ptr) + if return_code == W.WEECHAT_CONFIG_READ_OK: + return True + if return_code == W.WEECHAT_CONFIG_READ_MEMORY_ERROR: + return False + if return_code == W.WEECHAT_CONFIG_READ_FILE_NOT_FOUND: + return True + return False + + +class ConfigSection(object): + """A class representing a weechat config section. + Should not be used on its own, the WeechatConfig class uses this to build + config sections.""" + @classmethod + def build(cls, name, options): + def constructor(self, name, config_ptr, options): + self._ptr = W.config_new_section( + config_ptr, name, 0, 0, "", "", "", "", "", "", "", "", "", "" + ) + self._config_ptr = config_ptr + self._option_ptrs = {} + + for option in options: + self._add_option(option) + + attributes = { + option.name: cls.option_property( + option.name, option.type, cast_func=option.cast_func + ) + for option in options + } + attributes["__init__"] = constructor + + section_class = type(name.title() + "Section", (cls,), attributes) + return section_class + + def free(self): + W.config_section_free_options(self._ptr) + W.config_section_free(self._ptr) + + def _add_option(self, option): + cb = option.change_callback.__name__ if option.change_callback else "" + option_ptr = W.config_new_option( + self._config_ptr, + self._ptr, + option.name, + option.type, + option.description, + option.string_values, + option.min, + option.max, + option.value, + option.value, + 0, + "", + "", + cb, + "", + "", + "", + ) + + self._option_ptrs[option.name] = option_ptr + + @staticmethod + def option_property(name, option_type, evaluate=False, cast_func=None): + """Create a property for this class that makes the reading of config + option values pythonic. The option will be available as a property with + the name of the option. + If a cast function was defined for the option the property will pass + the option value to the cast function and return its result.""" + + def bool_getter(self): + return bool(W.config_boolean(self._option_ptrs[name])) + + def str_getter(self): + if cast_func: + return cast_func(W.config_string(self._option_ptrs[name])) + return W.config_string(self._option_ptrs[name]) + + def str_evaluate_getter(self): + return W.string_eval_expression( + W.config_string(self._option_ptrs[name]), {}, {}, {} + ) + + def int_getter(self): + if cast_func: + return cast_func(W.config_integer(self._option_ptrs[name])) + return W.config_integer(self._option_ptrs[name]) + + if option_type in ("string", "color"): + if evaluate: + return property(str_evaluate_getter) + return property(str_getter) + if option_type == "boolean": + return property(bool_getter) + if option_type == "integer": + return property(int_getter) + + +class MatrixConfig(WeechatConfig): + """Main matrix configuration file. + This class defines all the global matrix configuration options. + New global options should be added to the constructor of this class under + the appropriate section. + + There are three main sections defined: + Look: This section is for options that change the way matrix messages + are shown or the way the buffers are shown. + Color: This section should mainly be for color options, options that + change color schemes or themes should go to the look section. + Network: This section is for options that change the way the script + behaves, e.g. the way it communicates with the server, it handles + responses or any other behavioural change that doesn't fit in the + previous sections. + + There is a special section called server defined which contains per server + configuration options. Server options aren't defined here, they need to be + added in server.py + """ + + def __init__(self): + self.debug_buffer = "" + self.upload_buffer = "" + self.debug_category = "all" + self.page_up_hook = None + self.human_buffer_names = None + + look_options = [ + Option( + "redactions", + "integer", + "strikethrough|notice|delete", + 0, + 0, + "strikethrough", + ( + "Only notice redactions, strike through or delete " + "redacted messages" + ), + RedactType, + ), + Option( + "server_buffer", + "integer", + "merge_with_core|merge_without_core|independent", + 0, + 0, + "merge_with_core", + "Merge server buffers", + ServerBufferType, + config_server_buffer_cb, + ), + Option( + "max_typing_notice_item_length", + "integer", + "", + 10, + 1000, + "50", + ("Limit the length of the typing notice bar item."), + ), + Option( + "bar_item_typing_notice_prefix", + "string", + "", + 0, + 0, + "Typing: ", + ("Prefix for the typing notice bar item."), + ), + Option( + "encryption_warning_sign", + "string", + "", + 0, + 0, + "⚠️ ", + ("A sign that is used to signal trust issues in encrypted " + "rooms (note: content is evaluated, see /help eval)"), + eval_cast, + ), + Option( + "busy_sign", + "string", + "", + 0, + 0, + "⏳", + ("A sign that is used to signal that the client is busy e.g. " + "when the room backlog is fetching" + " (note: content is evaluated, see /help eval)"), + eval_cast, + ), + Option( + "encrypted_room_sign", + "string", + "", + 0, + 0, + "🔐", + ("A sign that is used to show that the current room is " + "encrypted " + "(note: content is evaluated, see /help eval)"), + eval_cast, + ), + Option( + "disconnect_sign", + "string", + "", + 0, + 0, + "❌", + ("A sign that is used to show that the server is disconnected " + "(note: content is evaluated, see /help eval)"), + eval_cast, + ), + Option( + "pygments_style", + "string", + "", + 0, + 0, + "native", + "Pygments style to use for highlighting source code blocks", + ), + Option( + "code_blocks", + "boolean", + "", + 0, + 0, + "on", + ("Display preformatted code blocks as rectangular areas by " + "padding them with whitespace up to the length of the longest" + " line (with optional margin)"), + ), + Option( + "code_block_margin", + "integer", + "", + 0, + 100, + "2", + ("Number of spaces to add as a margin around around a code " + "block"), + ), + Option( + "human_buffer_names", + "boolean", + "", + 0, + 0, + "off", + ("If turned on the buffer name will consist of the server " + "name and the room name instead of the Matrix room ID. Note, " + "this requires a change to the logger.file.mask setting " + "since conflicts can happen otherwise " + "(requires a script reload)."), + ), + ] + + network_options = [ + Option( + "max_initial_sync_events", + "integer", + "", + 1, + 10000, + "30", + ("How many events to fetch during the initial sync"), + ), + Option( + "max_backlog_sync_events", + "integer", + "", + 1, + 100, + "10", + ("How many events to fetch during backlog fetching"), + ), + Option( + "fetch_backlog_on_pgup", + "boolean", + "", + 0, + 0, + "on", + ("Fetch messages in the backlog on a window page up event"), + None, + config_pgup_cb, + ), + Option( + "debug_level", + "integer", + "error|warn|info|debug", + 0, + 0, + "error", + "Enable network protocol debugging.", + level_to_logbook, + config_log_level_cb, + ), + Option( + "debug_category", + "integer", + "all|http|client|events|responses|encryption", + 0, + 0, + "all", + "Debugging category", + logbook_category, + config_log_category_cb, + ), + Option( + "debug_buffer", + "boolean", + "", + 0, + 0, + "off", + ("Use a separate buffer for debug logs."), + ), + Option( + "lazy_load_room_users", + "boolean", + "", + 0, + 0, + "off", + ("If on, room users won't be loaded in the background " + "proactively, they will be loaded when the user switches to " + "the room buffer. This only affects non-encrypted rooms."), + ), + Option( + "max_nicklist_users", + "integer", + "", + 100, + 20000, + "5000", + ("Limit the number of users that are added to the nicklist. " + "Active users and users with a higher power level are always." + " Inactive users will be removed from the nicklist after a " + "day of inactivity."), + ), + Option( + "lag_reconnect", + "integer", + "", + 5, + 604800, + "90", + ("Reconnect to the server if the lag is greater than this " + "value (in seconds)"), + ), + Option( + "print_unconfirmed_messages", + "boolean", + "", + 0, + 0, + "on", + ("If off, messages are only printed after the server confirms " + "their receival. If on, messages are immediately printed but " + "colored differently until receival is confirmed."), + ), + Option( + "lag_min_show", + "integer", + "", + 1, + 604800, + "500", + ("minimum lag to show (in milliseconds)"), + ), + Option( + "typing_notice_conditions", + "string", + "", + 0, + 0, + "${typing_enabled}", + ("conditions to send typing notifications (note: content is " + "evaluated, see /help eval); besides the buffer and window " + "variables the typing_enabled variable is also expanded; " + "the typing_enabled variable can be manipulated with the " + "/room command, see /help room"), + ), + Option( + "read_markers_conditions", + "string", + "", + 0, + 0, + "${markers_enabled}", + ("conditions to send read markers (note: content is " + "evaluated, see /help eval); besides the buffer and window " + "variables the markers_enabled variable is also expanded; " + "the markers_enabled variable can be manipulated with the " + "/room command, see /help room"), + ), + Option( + "resending_ignores_devices", + "boolean", + "", + 0, + 0, + "on", + ("If on resending the same message to a room that contains " + "unverified devices will mark the devices as ignored and " + "continue sending the message. If off resending the message " + "will again fail and devices need to be marked as verified " + "one by one or the /send-anyways command needs to be used to " + "ignore them."), + ), + ] + + color_options = [ + Option( + "quote_fg", + "color", + "", + 0, + 0, + "lightgreen", + "Foreground color for matrix style blockquotes", + ), + Option( + "quote_bg", + "color", + "", + 0, + 0, + "default", + "Background counterpart of quote_fg", + ), + Option( + "error_message_fg", + "color", + "", + 0, + 0, + "darkgray", + ("Foreground color for error messages that appear inside a " + "room buffer (e.g. when a message errors out when sending or " + "when a message is redacted)"), + ), + Option( + "error_message_bg", + "color", + "", + 0, + 0, + "default", + "Background counterpart of error_message_fg.", + ), + Option( + "unconfirmed_message_fg", + "color", + "", + 0, + 0, + "darkgray", + ("Foreground color for messages that are printed out but the " + "server hasn't confirmed the that he received them."), + ), + Option( + "unconfirmed_message_bg", + "color", + "", + 0, + 0, + "default", + "Background counterpart of unconfirmed_message_fg." + ), + Option( + "untagged_code_fg", + "color", + "", + 0, + 0, + "blue", + ("Foreground color for code without a language specifier. " + "Also used for `inline code`."), + ), + Option( + "untagged_code_bg", + "color", + "", + 0, + 0, + "default", + "Background counterpart of untagged_code_fg", + ), + ] + + sections = [ + ("network", network_options), + ("look", look_options), + ("color", color_options), + ] + + super().__init__(sections) + + # The server section is essentially a section with subsections and no + # options, handle that case independently. + W.config_new_section( + self._ptr, + "server", + 0, + 0, + "matrix_config_server_read_cb", + "", + "matrix_config_server_write_cb", + "", + "", + "", + "", + "", + "", + "", + ) + + def read(self): + super().read() + self.human_buffer_names = self.look.human_buffer_names + + def free(self): + section_ptr = W.config_search_section(self._ptr, "server") + W.config_section_free(section_ptr) + super().free() diff --git a/.weechat/python/matrix/globals.py b/.weechat/python/matrix/globals.py new file mode 100644 index 0000000..7d34248 --- /dev/null +++ b/.weechat/python/matrix/globals.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2018, 2019 Damir Jelić +# +# Permission to use, copy, modify, and/or distribute this software for +# any purpose with or without fee is hereby granted, provided that the +# above copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER +# RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from __future__ import unicode_literals + +import sys +from typing import Dict, Optional +from logbook import Logger +from collections import OrderedDict + +from .utf import WeechatWrapper + +if False: + from .server import MatrixServer + from .config import MatrixConfig + from .uploads import Upload + + +try: + import weechat + + W = weechat if sys.hexversion >= 0x3000000 else WeechatWrapper(weechat) +except ImportError: + import matrix._weechat as weechat # type: ignore + + W = weechat + +SERVERS = dict() # type: Dict[str, MatrixServer] +CONFIG = None # type: Optional[MatrixConfig] +ENCRYPTION = True # type: bool +SCRIPT_NAME = "matrix" # type: str +MAX_EVENTS = 100 +TYPING_NOTICE_TIMEOUT = 4000 # 4 seconds typing notice lifetime +LOGGER = Logger("weechat-matrix") +UPLOADS = OrderedDict() # type: Dict[str, Upload] diff --git a/.weechat/python/matrix/server.py b/.weechat/python/matrix/server.py new file mode 100644 index 0000000..93b1568 --- /dev/null +++ b/.weechat/python/matrix/server.py @@ -0,0 +1,1900 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2018, 2019 Damir Jelić +# +# Permission to use, copy, modify, and/or distribute this software for +# any purpose with or without fee is hereby granted, provided that the +# above copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER +# RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from __future__ import unicode_literals + +import os +import pprint +import socket +import ssl +import time +import copy +from collections import defaultdict, deque +from atomicwrites import atomic_write +from typing import ( + Any, + Deque, + Dict, + Optional, + List, + NamedTuple, + DefaultDict, + Union +) + +from uuid import UUID + +from nio import ( + Api, + HttpClient, + LocalProtocolError, + LoginResponse, + Response, + Rooms, + RoomSendResponse, + RoomSendError, + SyncResponse, + PartialSyncResponse, + ShareGroupSessionResponse, + ShareGroupSessionError, + KeysQueryResponse, + KeysClaimResponse, + DevicesResponse, + UpdateDeviceResponse, + DeleteDevicesAuthResponse, + DeleteDevicesResponse, + TransportType, + RoomMessagesResponse, + EncryptionError, + GroupEncryptionError, + OlmTrustError, + ErrorResponse, + SyncError, + LoginError, + JoinedMembersResponse, + JoinedMembersError, + RoomKeyEvent, + KeyVerificationStart, + KeyVerificationCancel, + KeyVerificationKey, + KeyVerificationMac, + KeyVerificationEvent, + ToDeviceResponse, + ToDeviceError +) + +from . import globals as G +from .buffer import OwnAction, OwnMessage, RoomBuffer +from .config import ConfigSection, Option, ServerBufferType +from .globals import SCRIPT_NAME, SERVERS, W, MAX_EVENTS, TYPING_NOTICE_TIMEOUT +from .utf import utf8_decode +from .utils import create_server_buffer, key_from_value, server_buffer_prnt +from .uploads import Upload + +from .colors import Formatted, FormattedString, DEFAULT_ATTRIBUTES + +try: + from urllib.parse import urlparse +except ImportError: + from urlparse import urlparse + +try: + FileNotFoundError # type: ignore +except NameError: + FileNotFoundError = IOError + + +EncrytpionQueueItem = NamedTuple( + "EncrytpionQueueItem", + [ + ("message_type", str), + ("message", Union[Formatted, Upload]), + ], +) + + +class ServerConfig(ConfigSection): + def __init__(self, server_name, config_ptr): + # type: (str, str) -> None + self._server_name = server_name + self._config_ptr = config_ptr + self._option_ptrs = {} # type: Dict[str, str] + + options = [ + Option( + "autoconnect", + "boolean", + "", + 0, + 0, + "off", + ( + "automatically connect to the matrix server when weechat " + "is starting" + ), + ), + Option( + "address", + "string", + "", + 0, + 0, + "", + "Hostname or IP address for the server", + ), + Option( + "port", "integer", "", 0, 65535, "443", "Port for the server" + ), + Option( + "proxy", + "string", + "", + 0, + 0, + "", + ("Name of weechat proxy to use (see /help proxy)"), + ), + Option( + "ssl_verify", + "boolean", + "", + 0, + 0, + "on", + ("Check that the SSL connection is fully trusted"), + ), + Option( + "username", "string", "", 0, 0, "", "Username to use on server" + ), + Option( + "password", + "string", + "", + 0, + 0, + "", + ( + "Password for server (note: content is evaluated, see " + "/help eval)" + ), + ), + Option( + "device_name", + "string", + "", + 0, + 0, + "Weechat Matrix", + "Device name to use while logging in to the matrix server", + ), + Option( + "autoreconnect_delay", + "integer", + "", + 0, + 86400, + "10", + ("Delay (in seconds) before trying to reconnect to server"), + ), + ] + + section = W.config_search_section(config_ptr, "server") + self._ptr = section + + for option in options: + option_name = "{server}.{option}".format( + server=self._server_name, option=option.name + ) + + self._option_ptrs[option.name] = W.config_new_option( + config_ptr, + section, + option_name, + option.type, + option.description, + option.string_values, + option.min, + option.max, + option.value, + option.value, + 0, + "", + "", + "matrix_config_server_change_cb", + self._server_name, + "", + "", + ) + + autoconnect = ConfigSection.option_property("autoconnect", "boolean") + address = ConfigSection.option_property("address", "string") + port = ConfigSection.option_property("port", "integer") + proxy = ConfigSection.option_property("proxy", "string") + ssl_verify = ConfigSection.option_property("ssl_verify", "boolean") + username = ConfigSection.option_property("username", "string") + device_name = ConfigSection.option_property("device_name", "string") + reconnect_delay = ConfigSection.option_property("autoreconnect_delay", "integer") + password = ConfigSection.option_property( + "password", "string", evaluate=True + ) + + def free(self): + W.config_section_free_options(self._ptr) + + +class MatrixServer(object): + # pylint: disable=too-many-instance-attributes + def __init__(self, name, config_ptr): + # type: (str, str) -> None + # yapf: disable + self.name = name # type: str + self.user_id = "" + self.device_id = "" # type: str + + self.room_buffers = dict() # type: Dict[str, RoomBuffer] + self.buffers = dict() # type: Dict[str, str] + self.server_buffer = None # type: Optional[str] + self.fd_hook = None # type: Optional[str] + self.ssl_hook = None # type: Optional[str] + self.timer_hook = None # type: Optional[str] + self.numeric_address = "" # type: Optional[str] + + self._connected = False # type: bool + self.connecting = False # type: bool + self.reconnect_delay = 0 # type: int + self.reconnect_time = None # type: Optional[float] + self.sync_time = None # type: Optional[float] + self.socket = None # type: Optional[ssl.SSLSocket] + self.ssl_context = ssl.create_default_context() # type: ssl.SSLContext + self.transport_type = None # type: Optional[TransportType] + + # Enable http2 negotiation on the ssl context. + self.ssl_context.set_alpn_protocols(["h2", "http/1.1"]) + + try: + self.ssl_context.set_npn_protocols(["h2", "http/1.1"]) + except NotImplementedError: + pass + + self.address = None + self.homeserver = None + self.client = None + self.access_token = None # type: Optional[str] + self.next_batch = None # type: Optional[str] + self.transaction_id = 0 # type: int + self.lag = 0 # type: int + self.lag_done = False # type: bool + self.busy = False # type: bool + + self.send_fd_hook = None # type: Optional[str] + self.send_buffer = b"" # type: bytes + self.device_check_timestamp = None # type: Optional[int] + + self.device_deletion_queue = dict() # type: Dict[str, str] + + self.encryption_queue = defaultdict(deque) \ + # type: DefaultDict[str, Deque[EncrytpionQueueItem]] + self.backlog_queue = dict() # type: Dict[str, str] + + self.user_gc_time = time.time() # type: float + self.member_request_list = [] # type: List[str] + self.rooms_with_missing_members = [] # type: List[str] + self.lazy_load_hook = None # type: Optional[str] + self.partial_sync_hook = None # type: Optional[str] + + # These flags remember if we made some requests so that we don't + # make them again while we wait on a response, the flags need to be + # cleared when we disconnect. + self.keys_queried = False # type: bool + self.keys_claimed = defaultdict(bool) # type: Dict[str, bool] + self.group_session_shared = defaultdict(bool) # type: Dict[str, bool] + self.ignore_while_sharing = defaultdict(bool) + self.to_device_sent = [] + + self.config = ServerConfig(self.name, config_ptr) + self._create_session_dir() + # yapf: enable + + def _create_session_dir(self): + path = os.path.join("matrix", self.name) + if not W.mkdir_home(path, 0o700): + message = ( + "{prefix}matrix: Error creating server session " "directory" + ).format(prefix=W.prefix("error")) + W.prnt("", message) + + @property + def connected(self): + return self._connected + + @connected.setter + def connected(self, value): + self._connected = value + W.bar_item_update("buffer_modes") + W.bar_item_update("matrix_modes") + + def get_session_path(self): + home_dir = W.info_get("weechat_dir", "") + return os.path.join(home_dir, "matrix", self.name) + + def _load_device_id(self): + file_name = "{}{}".format(self.config.username, ".device_id") + path = os.path.join(self.get_session_path(), file_name) + + if not os.path.isfile(path): + return + + with open(path, "r") as device_file: + device_id = device_file.readline().rstrip() + if device_id: + self.device_id = device_id + + def save_device_id(self): + file_name = "{}{}".format(self.config.username, ".device_id") + path = os.path.join(self.get_session_path(), file_name) + + with atomic_write(path, overwrite=True) as device_file: + device_file.write(self.device_id) + + @staticmethod + def _parse_url(address, port): + if not address.startswith("http"): + address = "https://{}".format(address) + + parsed_url = urlparse(address) + + homeserver = parsed_url._replace( + netloc=parsed_url.hostname + ":{}".format(port) + ) + + return homeserver + + def _change_client(self): + homeserver = MatrixServer._parse_url( + self.config.address, + self.config.port + ) + self.address = homeserver.hostname + self.homeserver = homeserver + + self.client = HttpClient( + homeserver.geturl(), + self.config.username, + self.device_id, + self.get_session_path(), + ) + self.client.add_to_device_callback( + self.key_verification_cb, + KeyVerificationEvent + ) + + def key_verification_cb(self, event): + if isinstance(event, KeyVerificationStart): + self.info_highlight("{user} via {device} has started a key " + "verification process.\n" + "To accept use /olm verification " + "accept {user} {device}".format( + user=event.sender, + device=event.from_device + )) + + elif isinstance(event, KeyVerificationKey): + sas = self.client.key_verifications.get(event.transaction_id, None) + if not sas: + return + + if sas.canceled: + return + + device = sas.other_olm_device + emoji = sas.get_emoji() + + emojis = [x[0] for x in emoji] + descriptions = [x[1] for x in emoji] + + centered_width = 12 + + def center_emoji(emoji, width): + # Assume each emoji has width 2 + emoji_width = 2 + + # These are emojis that need VARIATION-SELECTOR-16 (U+FE0F) so + # that they are rendered with coloured glyphs. For these, we + # need to add an extra space after them so that they are + # rendered properly in weechat. + variation_selector_emojis = [ + '☁️', + '❤️', + '☂️', + '✏️', + '✂️', + '☎️', + '✈️' + ] + + # Hack to make weechat behave properly when one of the above is + # printed. + if emoji in variation_selector_emojis: + emoji += " " + + # This is a trick to account for the fact that emojis are wider + # than other monospace characters. + placeholder = '.' * emoji_width + + return placeholder.center(width).replace(placeholder, emoji) + + emoji_str = u"".join(center_emoji(e, centered_width) + for e in emojis) + desc = u"".join(d.center(centered_width) for d in descriptions) + short_string = u"\n".join([emoji_str, desc]) + + self.info_highlight(u"Short authentication string for " + u"{user} via {device}:\n{string}\n" + u"Confirm that the strings match with " + u"/olm verification confirm {user} " + u"{device}".format( + user=device.user_id, + device=device.id, + string=short_string + )) + + elif isinstance(event, KeyVerificationMac): + try: + sas = self.client.key_verifications[event.transaction_id] + except KeyError: + return + + device = sas.other_olm_device + + if sas.verified: + self.info_highlight("Device {} of user {} succesfully " + "verified".format( + device.id, + device.user_id + )) + + elif isinstance(event, KeyVerificationCancel): + self.info_highlight("The interactive device verification with " + "user {} got canceled: {}.".format( + event.sender, + event.reason + )) + + def update_option(self, option, option_name): + if option_name == "address": + self._change_client() + elif option_name == "port": + self._change_client() + elif option_name == "ssl_verify": + value = W.config_boolean(option) + if value: + self.ssl_context.verify_mode = ssl.CERT_REQUIRED + self.ssl_context.check_hostname = True + else: + self.ssl_context.check_hostname = False + self.ssl_context.verify_mode = ssl.CERT_NONE + elif option_name == "username": + value = W.config_string(option) + self.access_token = "" + + self._load_device_id() + + if self.client: + self.client.user = value + if self.device_id: + self.client.device_id = self.device_id + else: + pass + + def send_or_queue(self, request): + # type: (bytes) -> None + self.send(request) + + def try_send(self, message): + # type: (MatrixServer, bytes) -> bool + + sock = self.socket + + if not sock: + return False + + total_sent = 0 + message_length = len(message) + + while total_sent < message_length: + try: + sent = sock.send(message[total_sent:]) + + except ssl.SSLWantWriteError: + hook = W.hook_fd(sock.fileno(), 0, 1, 0, "send_cb", self.name) + self.send_fd_hook = hook + self.send_buffer = message[total_sent:] + return True + + except socket.error as error: + self._abort_send() + + errno = "error" + str(error.errno) + " " if error.errno else "" + strerr = error.strerror if error.strerror else "Unknown reason" + strerr = errno + strerr + + error_message = ( + "{prefix}Error while writing to " "socket: {error}" + ).format(prefix=W.prefix("network"), error=strerr) + + server_buffer_prnt(self, error_message) + server_buffer_prnt( + self, + ("{prefix}matrix: disconnecting from server...").format( + prefix=W.prefix("network") + ), + ) + + self.disconnect() + return False + + if sent == 0: + self._abort_send() + + server_buffer_prnt( + self, + "{prefix}matrix: Error while writing to socket".format( + prefix=W.prefix("network") + ), + ) + server_buffer_prnt( + self, + ("{prefix}matrix: disconnecting from server...").format( + prefix=W.prefix("network") + ), + ) + self.disconnect() + return False + + total_sent = total_sent + sent + + self._finalize_send() + return True + + def _abort_send(self): + self.send_buffer = b"" + + def _finalize_send(self): + # type: (MatrixServer) -> None + self.send_buffer = b"" + + def info_highlight(self, message): + buf = "" + if self.server_buffer: + buf = self.server_buffer + + msg = "{}{}: {}".format(W.prefix("network"), SCRIPT_NAME, message) + W.prnt_date_tags(buf, 0, "notify_highlight", msg) + + def info(self, message): + buf = "" + if self.server_buffer: + buf = self.server_buffer + + msg = "{}{}: {}".format(W.prefix("network"), SCRIPT_NAME, message) + W.prnt(buf, msg) + + def error(self, message): + buf = "" + if self.server_buffer: + buf = self.server_buffer + + msg = "{}{}: {}".format(W.prefix("error"), SCRIPT_NAME, message) + W.prnt(buf, msg) + + def send(self, data): + # type: (bytes) -> bool + self.try_send(data) + + return True + + def reconnect(self): + message = ("{prefix}matrix: reconnecting to server...").format( + prefix=W.prefix("network") + ) + + server_buffer_prnt(self, message) + + self.reconnect_time = None + + if not self.connect(): + self.schedule_reconnect() + + def schedule_reconnect(self): + # type: (MatrixServer) -> None + self.connecting = True + self.reconnect_time = time.time() + + if self.reconnect_delay: + self.reconnect_delay = self.reconnect_delay * 2 + else: + self.reconnect_delay = self.config.reconnect_delay + + message = ( + "{prefix}matrix: reconnecting to server in {t} " "seconds" + ).format(prefix=W.prefix("network"), t=self.reconnect_delay) + + server_buffer_prnt(self, message) + + def _close_socket(self): + # type: () -> None + if self.socket: + try: + self.socket.shutdown(socket.SHUT_RDWR) + except socket.error: + pass + + try: + self.socket.close() + except OSError: + pass + + def disconnect(self, reconnect=True): + # type: (bool) -> None + if self.fd_hook: + W.unhook(self.fd_hook) + + self._close_socket() + + self.fd_hook = None + self.socket = None + self.connected = False + self.access_token = "" + + self.send_buffer = b"" + self.transport_type = None + self.member_request_list = [] + + if self.client: + try: + self.client.disconnect() + except LocalProtocolError: + pass + + self.lag = 0 + W.bar_item_update("lag") + self.reconnect_time = None + + # Clear our request flags. + self.keys_queried = False + self.keys_claimed = defaultdict(bool) + self.group_session_shared = defaultdict(bool) + self.ignore_while_sharing = defaultdict(bool) + self.to_device_sent = [] + + if self.server_buffer: + message = ("{prefix}matrix: disconnected from server").format( + prefix=W.prefix("network") + ) + server_buffer_prnt(self, message) + + if reconnect: + self.schedule_reconnect() + else: + self.reconnect_delay = 0 + + def connect(self): + # type: (MatrixServer) -> int + if not self.config.address or not self.config.port: + message = "{prefix}Server address or port not set".format( + prefix=W.prefix("error") + ) + W.prnt("", message) + return False + + if not self.config.username or not self.config.password: + message = "{prefix}User or password not set".format( + prefix=W.prefix("error") + ) + W.prnt("", message) + return False + + if self.connected: + return True + + if not self.server_buffer: + create_server_buffer(self) + + if not self.timer_hook: + self.timer_hook = W.hook_timer( + 1 * 1000, 0, 0, "matrix_timer_cb", self.name + ) + + ssl_message = " (SSL)" if self.ssl_context.check_hostname else "" + + message = ( + "{prefix}matrix: Connecting to " "{server}:{port}{ssl}..." + ).format( + prefix=W.prefix("network"), + server=self.address, + port=self.config.port, + ssl=ssl_message, + ) + + W.prnt(self.server_buffer, message) + + W.hook_connect( + self.config.proxy, + self.address, + self.config.port, + 1, + 0, + "", + "connect_cb", + self.name, + ) + + return True + + def schedule_sync(self): + self.sync_time = time.time() + + def sync(self, timeout=None, sync_filter=None): + # type: (Optional[int], Optional[Dict[Any, Any]]) -> None + if not self.client: + return + + self.sync_time = None + _, request = self.client.sync(timeout, sync_filter) + self.send_or_queue(request) + + def login(self): + # type: () -> None + if not self.client: + return + + if self.client.logged_in: + msg = ( + "{prefix}{script_name}: Already logged in, " "syncing..." + ).format(prefix=W.prefix("network"), script_name=SCRIPT_NAME) + W.prnt(self.server_buffer, msg) + timeout = 0 if self.transport_type == TransportType.HTTP else 30000 + sync_filter = { + "room": { + "timeline": {"limit": 5000}, + "state": {"lazy_load_members": True} + } + } + self.sync(timeout, sync_filter) + return + + _, request = self.client.login( + self.config.password, self.config.device_name + ) + self.send_or_queue(request) + + msg = "{prefix}matrix: Logging in...".format( + prefix=W.prefix("network") + ) + + W.prnt(self.server_buffer, msg) + + def devices(self): + _, request = self.client.devices() + self.send_or_queue(request) + + def delete_device(self, device_id, auth=None): + uuid, request = self.client.delete_devices([device_id], auth) + self.device_deletion_queue[uuid] = device_id + self.send_or_queue(request) + return + + def rename_device(self, device_id, display_name): + content = { + "display_name": display_name + } + + _, request = self.client.update_device(device_id, content) + self.send_or_queue(request) + + def room_send_state(self, room_buffer, body, event_type): + _, request = self.client.room_put_state( + room_buffer.room.room_id, event_type, body + ) + self.send_or_queue(request) + + def room_send_redaction(self, room_buffer, event_id, reason=None): + _, request = self.client.room_redact( + room_buffer.room.room_id, event_id, reason + ) + self.send_or_queue(request) + + def room_kick(self, room_buffer, user_id, reason=None): + _, request = self.client.room_kick( + room_buffer.room.room_id, user_id, reason + ) + self.send_or_queue(request) + + def room_invite(self, room_buffer, user_id): + _, request = self.client.room_invite(room_buffer.room.room_id, user_id) + self.send_or_queue(request) + + def room_join(self, room_id): + _, request = self.client.join(room_id) + self.send_or_queue(request) + + def room_leave(self, room_id): + _, request = self.client.room_leave(room_id) + self.send_or_queue(request) + + def room_get_messages(self, room_id): + room_buffer = self.find_room_from_id(room_id) + + # We're already fetching old messages + if room_buffer.backlog_pending: + return + + if not room_buffer.prev_batch: + return + + uuid, request = self.client.room_messages( + room_id, + room_buffer.prev_batch, + limit=10) + + room_buffer.backlog_pending = True + self.backlog_queue[uuid] = room_id + self.send_or_queue(request) + + def room_send_read_marker(self, room_id, event_id): + """Send read markers for the provided room. + + Args: + room_id(str): the room for which the read markers should + be sent. + event_id(str): the event id where to set the marker + """ + if not self.connected: + return + + _, request = self.client.room_read_markers( + room_id, + fully_read_event=event_id, + read_event=event_id) + self.send(request) + + def room_send_typing_notice(self, room_buffer): + """Send a typing notice for the provided room. + + Args: + room_buffer(RoomBuffer): the room for which the typing notice needs + to be sent. + """ + if not self.connected: + return + + input = room_buffer.weechat_buffer.input + + typing_enabled = bool(int(W.string_eval_expression( + G.CONFIG.network.typing_notice_conditions, + {}, + {"typing_enabled": str(int(room_buffer.typing_enabled))}, + {"type": "condition"} + ))) + + if not typing_enabled: + return + + # Don't send a typing notice if the user is typing in a weechat command + if input.startswith("/") and not input.startswith("//"): + return + + # Don't send a typing notice if we only typed a couple of letters. + elif len(input) < 4 and not room_buffer.typing: + return + + # If we were typing already and our input bar now has no letters or + # only a couple of letters stop the typing notice. + elif len(input) < 4: + _, request = self.client.room_typing( + room_buffer.room.room_id, + typing_state=False) + room_buffer.typing = False + self.send(request) + return + + # Don't send out a typing notice if we already sent one out and it + # didn't expire yet. + if not room_buffer.typing_notice_expired: + return + + _, request = self.client.room_typing( + room_buffer.room.room_id, + typing_state=True, + timeout=TYPING_NOTICE_TIMEOUT) + + room_buffer.typing = True + self.send(request) + + def room_send_upload( + self, + upload + ): + """Send a room message containing the mxc URI of an upload.""" + try: + room_buffer = self.find_room_from_id(upload.room_id) + except (ValueError, KeyError): + return True + + assert self.client + + if room_buffer.room.encrypted: + assert upload.encrypt + + content = upload.content + + try: + uuid = self.room_send_event(upload.room_id, content) + except (EncryptionError, GroupEncryptionError): + message = EncrytpionQueueItem(upload.msgtype, upload) + self.encryption_queue[upload.room_id].append(message) + return False + + attributes = DEFAULT_ATTRIBUTES.copy() + formatted = Formatted([FormattedString( + upload.render, + attributes + )]) + + own_message = OwnMessage( + self.user_id, 0, "", uuid, upload.room_id, formatted + ) + + room_buffer.sent_messages_queue[uuid] = own_message + self.print_unconfirmed_message(room_buffer, own_message) + + return True + + def share_group_session( + self, + room_id, + ignore_missing_sessions=False, + ignore_unverified_devices=False + ): + + self.ignore_while_sharing[room_id] = ignore_unverified_devices + + _, request = self.client.share_group_session( + room_id, + ignore_missing_sessions=ignore_missing_sessions, + ignore_unverified_devices=ignore_unverified_devices + ) + self.send(request) + self.group_session_shared[room_id] = True + + def room_send_event( + self, + room_id, # type: str + content, # type: Dict[str, str] + event_type="m.room.message", # type: str + ignore_unverified_devices=False, # type: bool + ): + # type: (...) -> UUID + assert self.client + + try: + uuid, request = self.client.room_send( + room_id, event_type, content + ) + self.send(request) + return uuid + except GroupEncryptionError: + try: + if not self.group_session_shared[room_id]: + self.share_group_session( + room_id, + ignore_unverified_devices=ignore_unverified_devices + ) + raise + + except EncryptionError: + if not self.keys_claimed[room_id]: + _, request = self.client.keys_claim(room_id) + self.keys_claimed[room_id] = True + self.send(request) + raise + + def room_send_message( + self, + room_buffer, # type: RoomBuffer + formatted, # type: Formatted + msgtype="m.text", # type: str + ignore_unverified_devices=False, # type: bool + ): + # type: (...) -> bool + room = room_buffer.room + + assert self.client + + content = {"msgtype": msgtype, "body": formatted.to_plain()} + + if formatted.is_formatted(): + content["format"] = "org.matrix.custom.html" + content["formatted_body"] = formatted.to_html() + + try: + uuid = self.room_send_event( + room.room_id, + content, + ignore_unverified_devices=ignore_unverified_devices + ) + except (EncryptionError, GroupEncryptionError): + message = EncrytpionQueueItem(msgtype, formatted) + self.encryption_queue[room.room_id].append(message) + return False + + if msgtype == "m.emote": + message_class = OwnAction + else: + message_class = OwnMessage + + own_message = message_class( + self.user_id, 0, "", uuid, room.room_id, formatted + ) + + room_buffer.sent_messages_queue[uuid] = own_message + self.print_unconfirmed_message(room_buffer, own_message) + + return True + + def print_unconfirmed_message(self, room_buffer, message): + """Print an outoing message before getting a recieve confirmation. + + The message is printed out greyed out and only printed out if the + client is configured to do so. The message needs to be later modified + to contain proper coloring, this is done in the + replace_printed_line_by_uuid() method of the RoomBuffer class. + + Args: + room_buffer(RoomBuffer): the buffer of the room where the message + needs to be printed out + message(OwnMessages): the message that should be printed out + """ + if G.CONFIG.network.print_unconfirmed_messages: + room_buffer.printed_before_ack_queue.append(message.uuid) + plain_message = message.formatted_message.to_weechat() + plain_message = W.string_remove_color(plain_message, "") + attributes = DEFAULT_ATTRIBUTES.copy() + attributes["fgcolor"] = G.CONFIG.color.unconfirmed_message_fg + attributes["bgcolor"] = G.CONFIG.color.unconfirmed_message_bg + new_formatted = Formatted([FormattedString( + plain_message, + attributes + )]) + + new_message = copy.copy(message) + new_message.formatted_message = new_formatted + + if isinstance(new_message, OwnAction): + room_buffer.self_action(new_message) + elif isinstance(new_message, OwnMessage): + room_buffer.self_message(new_message) + + def keys_upload(self): + _, request = self.client.keys_upload() + self.send_or_queue(request) + + def keys_query(self): + _, request = self.client.keys_query() + self.keys_queried = True + self.send_or_queue(request) + + def get_joined_members(self, room_id): + if not self.connected: + return + if room_id in self.member_request_list: + return + + self.member_request_list.append(room_id) + _, request = self.client.joined_members(room_id) + self.send(request) + + def _print_message_error(self, message): + server_buffer_prnt( + self, + ( + "{prefix}Unhandled {status_code} error, please " + "inform the developers about this." + ).format( + prefix=W.prefix("error"), status_code=message.response.status + ), + ) + + server_buffer_prnt(self, pprint.pformat(message.__class__.__name__)) + server_buffer_prnt(self, pprint.pformat(message.request.payload)) + server_buffer_prnt(self, pprint.pformat(message.response.body)) + + def handle_own_messages_error(self, response): + room_buffer = self.room_buffers[response.room_id] + + if response.uuid not in room_buffer.printed_before_ack_queue: + return + + message = room_buffer.sent_messages_queue.pop(response.uuid) + room_buffer.mark_message_as_unsent(response.uuid, message) + room_buffer.printed_before_ack_queue.remove(response.uuid) + + def handle_own_messages(self, response): + def send_marker(): + if not room_buffer.read_markers_enabled: + return + + self.room_send_read_marker(response.room_id, response.event_id) + room_buffer.last_read_event = response.event_id + + room_buffer = self.room_buffers[response.room_id] + + message = room_buffer.sent_messages_queue.pop(response.uuid, None) + + # The message might have been returned in a sync response before we got + # a room send response. + if not message: + return + + message.event_id = response.event_id + # We already printed the message, just modify it to contain the proper + # colors and formatting. + if response.uuid in room_buffer.printed_before_ack_queue: + room_buffer.replace_printed_line_by_uuid(response.uuid, message) + room_buffer.printed_before_ack_queue.remove(response.uuid) + send_marker() + return + + if isinstance(message, OwnAction): + room_buffer.self_action(message) + send_marker() + return + if isinstance(message, OwnMessage): + room_buffer.self_message(message) + send_marker() + return + + raise NotImplementedError( + "Unsupported message of type {}".format(type(message)) + ) + + def handle_backlog_response(self, response): + room_id = self.backlog_queue.pop(response.uuid) + room_buffer = self.find_room_from_id(room_id) + + room_buffer.handle_backlog(response) + + def handle_devices_response(self, response): + if not response.devices: + m = "{}{}: No devices found for this account".format( + W.prefix("error"), + SCRIPT_NAME) + W.prnt(self.server_buffer, m) + + header = (W.prefix("network") + SCRIPT_NAME + ": Devices for " + "server {}{}{}:\n" + " Device ID Device Name " + "Last Seen").format( + W.color("chat_server"), + self.name, + W.color("reset") + ) + W.prnt(self.server_buffer, header) + + lines = [] + for device in response.devices: + last_seen_date = ("?" if not device.last_seen_date else + device.last_seen_date.strftime("%Y/%m/%d %H:%M")) + last_seen = "{ip} @ {date}".format( + ip=device.last_seen_ip or "?", + date=last_seen_date + ) + device_color = ("chat_self" if device.id == self.device_id else + W.info_get("nick_color_name", device.id)) + bold = W.color("bold") if device.id == self.device_id else "" + line = " {}{}{:<18}{}{:<34}{:<}".format( + bold, + W.color(device_color), + device.id, + W.color("resetcolor"), + device.display_name or "", + last_seen + ) + lines.append(line) + W.prnt(self.server_buffer, "\n".join(lines)) + + def _handle_login(self, response): + self.access_token = response.access_token + self.user_id = response.user_id + self.client.access_token = response.access_token + self.device_id = response.device_id + self.save_device_id() + + message = "{prefix}matrix: Logged in as {user}".format( + prefix=W.prefix("network"), user=self.user_id + ) + + W.prnt(self.server_buffer, message) + + if not self.client.olm_account_shared: + self.keys_upload() + + sync_filter = { + "room": { + "timeline": { + "limit": G.CONFIG.network.max_initial_sync_events + }, + "state": {"lazy_load_members": True} + } + } + self.sync(timeout=0, sync_filter=sync_filter) + + def _handle_room_info(self, response): + for room_id, info in response.rooms.invite.items(): + room = self.client.invited_rooms.get(room_id, None) + + if room: + if room.inviter: + inviter_msg = " by {}{}".format( + W.color("chat_nick_other"), room.inviter + ) + else: + inviter_msg = "" + + self.info_highlight( + "You have been invited to {} {}({}{}{}){}" + "{}".format( + room.display_name, + W.color("chat_delimiters"), + W.color("chat_channel"), + room_id, + W.color("chat_delimiters"), + W.color("reset"), + inviter_msg, + ) + ) + else: + self.info_highlight("You have been invited to {}.".format( + room_id + )) + + for room_id, info in response.rooms.leave.items(): + if room_id not in self.buffers: + continue + + room_buffer = self.find_room_from_id(room_id) + room_buffer.handle_left_room(info) + + for room_id, info in response.rooms.join.items(): + if room_id not in self.buffers: + self.create_room_buffer(room_id, info.timeline.prev_batch) + + room_buffer = self.find_room_from_id(room_id) + room_buffer.handle_joined_room(info) + + def add_unhandled_users(self, rooms, n): + # type: (List[RoomBuffer], int) -> bool + total_users = 0 + + while total_users <= n: + try: + room_buffer = rooms.pop() + except IndexError: + return False + + handled_users = 0 + + users = room_buffer.unhandled_users + + for user_id in users: + room_buffer.add_user(user_id, 0, True) + handled_users += 1 + total_users += 1 + + if total_users >= n: + room_buffer.unhandled_users = users[handled_users:] + rooms.append(room_buffer) + return True + + room_buffer.unhandled_users = [] + + return False + + def _hook_lazy_user_adding(self): + if not self.lazy_load_hook: + hook = W.hook_timer(1 * 1000, 0, 0, + "matrix_load_users_cb", self.name) + self.lazy_load_hook = hook + + def decrypt_printed_messages(self, key_event): + """Decrypt already printed messages and send them to the buffer""" + try: + room_buffer = self.find_room_from_id(key_event.room_id) + except KeyError: + return + + decrypted_events = [] + + for undecrypted_event in room_buffer.undecrypted_events: + if undecrypted_event.session_id != key_event.session_id: + continue + + event = self.client.decrypt_event(undecrypted_event) + if event: + decrypted_events.append((undecrypted_event, event)) + + for event_pair in decrypted_events: + undecrypted_event, event = event_pair + room_buffer.undecrypted_events.remove(undecrypted_event) + room_buffer.replace_undecrypted_line(event) + + def start_verification(self, device): + _, request = self.client.start_key_verification(device) + self.send(request) + self.info("Starting an interactive device verification with " + "{} {}".format(device.user_id, device.id)) + + def accept_sas(self, sas): + _, request = self.client.accept_key_verification(sas.transaction_id) + self.send(request) + + def cancel_sas(self, sas): + _, request = self.client.cancel_key_verification(sas.transaction_id) + self.send(request) + + def to_device(self, message): + _, request = self.client.to_device(message) + self.send(request) + + def confirm_sas(self, sas): + _, request = self.client.confirm_short_auth_string(sas.transaction_id) + self.send(request) + + device = sas.other_olm_device + + if sas.verified: + self.info("Device {} of user {} succesfully verified".format( + device.id, + device.user_id + )) + else: + self.info("Waiting for {} to confirm...".format(device.user_id)) + + def _handle_sync(self, response): + # we got the same batch again, nothing to do + if self.next_batch == response.next_batch: + self.schedule_sync() + return + + self._handle_room_info(response) + + for event in response.to_device_events: + if isinstance(event, RoomKeyEvent): + message = { + "sender": event.sender, + "sender_key": event.sender_key, + "room_id": event.room_id, + "session_id": event.session_id, + "algorithm": event.algorithm, + "server": self.name, + } + W.hook_hsignal_send("matrix_room_key_received", message) + + # TODO try to decrypt some cached undecrypted messages with the + # new key + # self.decrypt_printed_messages(event) + + # Full sync response handle everything. + if isinstance(response, SyncResponse): + if self.client.should_upload_keys: + self.keys_upload() + + if self.client.should_query_keys and not self.keys_queried: + self.keys_query() + + for room_buffer in self.room_buffers.values(): + # It's our initial sync, we need to fetch room members, so add + # the room to the missing members queue. + if not self.next_batch: + if (not G.CONFIG.network.lazy_load_room_users + or room_buffer.room.encrypted): + self.rooms_with_missing_members.append( + room_buffer.room.room_id + ) + if room_buffer.unhandled_users: + self._hook_lazy_user_adding() + break + + self.next_batch = response.next_batch + self.schedule_sync() + W.bar_item_update("matrix_typing_notice") + + if self.rooms_with_missing_members: + self.get_joined_members(self.rooms_with_missing_members.pop()) + else: + if not self.partial_sync_hook: + hook = W.hook_timer(1 * 100, 0, 0, "matrix_partial_sync_cb", + self.name) + self.partial_sync_hook = hook + self.busy = True + W.bar_item_update("buffer_modes") + W.bar_item_update("matrix_modes") + + def handle_delete_device_auth(self, response): + device_id = self.device_deletion_queue.pop(response.uuid, None) + + if not device_id: + return + + for flow in response.flows: + if "m.login.password" in flow["stages"]: + session = response.session + auth = { + "type": "m.login.password", + "session": session, + "user": self.client.user_id, + "password": self.config.password + } + self.delete_device(device_id, auth) + return + + self.error("No supported auth method for device deletion found.") + + def handle_error_response(self, response): + self.error("Error: {}".format(str(response))) + + if isinstance(response, (SyncError, LoginError)): + self.disconnect() + elif isinstance(response, JoinedMembersError): + self.rooms_with_missing_members.append(response.room_id) + self.get_joined_members(self.rooms_with_missing_members.pop()) + elif isinstance(response, RoomSendError): + self.handle_own_messages_error(response) + elif isinstance(response, ShareGroupSessionError): + self.group_session_shared[response.room_id] = False + self.share_group_session( + response.room_id, + False, + self.ignore_while_sharing[response.room_id] + ) + + elif isinstance(response, ToDeviceError): + try: + self.to_device_sent.remove(response.to_device_message) + except ValueError: + pass + + def handle_response(self, response): + # type: (Response) -> None + response_lag = response.elapsed + + current_lag = 0 + + if self.client: + current_lag = self.client.lag + + if response_lag >= current_lag: + self.lag = response_lag * 1000 + self.lag_done = True + W.bar_item_update("lag") + + if isinstance(response, ErrorResponse): + self.handle_error_response(response) + + elif isinstance(response, ToDeviceResponse): + try: + self.to_device_sent.remove(response.to_device_message) + except ValueError: + pass + + elif isinstance(response, LoginResponse): + self._handle_login(response) + + elif isinstance(response, (SyncResponse, PartialSyncResponse)): + self._handle_sync(response) + + elif isinstance(response, RoomSendResponse): + self.handle_own_messages(response) + + elif isinstance(response, RoomMessagesResponse): + self.handle_backlog_response(response) + + elif isinstance(response, DevicesResponse): + self.handle_devices_response(response) + + elif isinstance(response, UpdateDeviceResponse): + self.info("Device name successfully updated") + + elif isinstance(response, DeleteDevicesAuthResponse): + self.handle_delete_device_auth(response) + + elif isinstance(response, DeleteDevicesResponse): + self.info("Device successfully deleted") + + elif isinstance(response, KeysQueryResponse): + self.keys_queried = False + W.bar_item_update("buffer_modes") + W.bar_item_update("matrix_modes") + + for user_id, device_dict in response.changed.items(): + for device in device_dict.values(): + message = { + "user_id": user_id, + "device_id": device.id, + "ed25519": device.ed25519, + "curve25519": device.curve25519, + "deleted": str(device.deleted) + } + W.hook_hsignal_send("matrix_device_changed", message) + + elif isinstance(response, JoinedMembersResponse): + self.member_request_list.remove(response.room_id) + room_buffer = self.room_buffers[response.room_id] + users = [user.user_id for user in response.members] + + # Don't add the users directly use the lazy load hook. + room_buffer.unhandled_users += users + self._hook_lazy_user_adding() + room_buffer.members_fetched = True + room_buffer.update_buffer_name() + + # Fetch the users for the next room. + if self.rooms_with_missing_members: + self.get_joined_members(self.rooms_with_missing_members.pop()) + # We are done adding all the users, do a full key query now since + # the client knows all the encrypted room members. + else: + if self.client.should_query_keys and not self.keys_queried: + self.keys_query() + + elif isinstance(response, KeysClaimResponse): + self.keys_claimed[response.room_id] = False + try: + self.share_group_session( + response.room_id, + True, + self.ignore_while_sharing[response.room_id] + ) + except OlmTrustError as e: + m = ("Untrusted devices found in room: {}".format(e)) + room_buffer = self.find_room_from_id(response.room_id) + room_buffer.error(m) + + try: + item = self.encryption_queue[response.room_id][0] + if item.message_type not in ["m.file", "m.video", + "m.audio", "m.image"]: + room_buffer.last_message = item.message + except IndexError: + pass + + self.encryption_queue[response.room_id].clear() + return + + elif isinstance(response, ShareGroupSessionResponse): + room_id = response.room_id + self.group_session_shared[response.room_id] = False + ignore_unverified = self.ignore_while_sharing[response.room_id] + self.ignore_while_sharing[response.room_id] = False + + room_buffer = self.room_buffers[room_id] + + while self.encryption_queue[room_id]: + item = self.encryption_queue[room_id].popleft() + try: + if item.message_type in [ + "m.file", + "m.video", + "m.audio", + "m.image" + ]: + ret = self.room_send_upload(item.message) + else: + ret = self.room_send_message( + room_buffer, + item.message, + item.message_type, + ignore_unverified_devices=ignore_unverified + ) + + if not ret: + self.encryption_queue[room_id].pop() + self.encryption_queue[room_id].appendleft(message) + break + + except OlmTrustError: + self.encryption_queue[room_id].clear() + + # If the item is a normal user message store it in the + # buffer to enable the send-anyways functionality. + if item.message_type not in ["m.file", "m.video", + "m.audio", "m.image"]: + room_buffer.last_message = item.message + + break + + def create_room_buffer(self, room_id, prev_batch): + room = self.client.rooms[room_id] + buf = RoomBuffer(room, self.name, self.homeserver, prev_batch) + + if room.members_synced: + buf.members_fetched = True + + self.room_buffers[room_id] = buf + self.buffers[room_id] = buf.weechat_buffer._ptr + + def find_room_from_ptr(self, pointer): + try: + room_id = key_from_value(self.buffers, pointer) + room_buffer = self.room_buffers[room_id] + + return room_buffer + except (ValueError, KeyError): + return None + + def find_room_from_id(self, room_id): + room_buffer = self.room_buffers[room_id] + return room_buffer + + def garbage_collect_users(self): + """ Remove inactive users. + This tries to keep the number of users added to the nicklist less than + the configuration option matrix.network.max_nicklist_users. It + removes users that have not been active for a day until there are + less than max_nicklist_users or no users are left for removal. + It never removes users that have a bigger power level than the + default one. + This function is run every hour by the server timer callback""" + + now = time.time() + self.user_gc_time = now + + def day_passed(t1, t2): + return (t2 - t1) > 86400 + + for room_buffer in self.room_buffers.values(): + to_remove = max( + (len(room_buffer.displayed_nicks) - + G.CONFIG.network.max_nicklist_users), + 0 + ) + + if not to_remove: + continue + + removed = 0 + removed_user_ids = [] + + for user_id, nick in room_buffer.displayed_nicks.items(): + user = room_buffer.weechat_buffer.users[nick] + + if (not user.speaking_time or + day_passed(user.speaking_time, now)): + room_buffer.weechat_buffer.part(nick, 0, False) + removed_user_ids.append(user_id) + removed += 1 + + if removed >= to_remove: + break + + for user_id in removed_user_ids: + del room_buffer.displayed_nicks[user_id] + + def buffer_merge(self): + if not self.server_buffer: + return + + buf = self.server_buffer + + if G.CONFIG.look.server_buffer == ServerBufferType.MERGE_CORE: + num = W.buffer_get_integer(W.buffer_search_main(), "number") + W.buffer_unmerge(buf, num + 1) + W.buffer_merge(buf, W.buffer_search_main()) + elif G.CONFIG.look.server_buffer == ServerBufferType.MERGE: + if SERVERS: + first = None + for server in SERVERS.values(): + if server.server_buffer: + first = server.server_buffer + break + if first: + num = W.buffer_get_integer( + W.buffer_search_main(), "number" + ) + W.buffer_unmerge(buf, num + 1) + if buf is not first: + W.buffer_merge(buf, first) + else: + num = W.buffer_get_integer(W.buffer_search_main(), "number") + W.buffer_unmerge(buf, num + 1) + + +@utf8_decode +def matrix_config_server_read_cb( + data, config_file, section, option_name, value +): + + return_code = W.WEECHAT_CONFIG_OPTION_SET_ERROR + + if option_name: + server_name, option = option_name.rsplit(".", 1) + server = None + + if server_name in SERVERS: + server = SERVERS[server_name] + else: + server = MatrixServer(server_name, config_file) + SERVERS[server.name] = server + + # Ignore invalid options + if option in server.config._option_ptrs: + return_code = W.config_option_set( + server.config._option_ptrs[option], value, 1 + ) + + # TODO print out error message in case of erroneous return_code + + return return_code + + +@utf8_decode +def matrix_config_server_write_cb(data, config_file, section_name): + if not W.config_write_line(config_file, section_name, ""): + return W.WECHAT_CONFIG_WRITE_ERROR + + for server in SERVERS.values(): + for option in server.config._option_ptrs.values(): + if not W.config_write_option(config_file, option): + return W.WECHAT_CONFIG_WRITE_ERROR + + return W.WEECHAT_CONFIG_WRITE_OK + + +@utf8_decode +def matrix_config_server_change_cb(server_name, option): + # type: (str, str) -> int + server = SERVERS[server_name] + option_name = None + + # The function config_option_get_string() is used to get differing + # properties from a config option, sadly it's only available in the plugin + # API of weechat. + option_name = key_from_value(server.config._option_ptrs, option) + server.update_option(option, option_name) + + return 1 + + +@utf8_decode +def matrix_partial_sync_cb(server_name, remaining_calls): + start = time.time() + server = SERVERS[server_name] + W.unhook(server.partial_sync_hook) + server.partial_sync_hook = None + + response = server.client.next_response(MAX_EVENTS) + + while response: + server.handle_response(response) + current = time.time() + if current - start >= 0.1: + break + response = server.client.next_response(MAX_EVENTS) + + if not server.partial_sync_hook: + server.busy = False + W.bar_item_update("buffer_modes") + W.bar_item_update("matrix_modes") + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_load_users_cb(server_name, remaining_calls): + server = SERVERS[server_name] + start = time.time() + + rooms = [x for x in server.room_buffers.values() if x.unhandled_users] + + while server.add_unhandled_users(rooms, 100): + current = time.time() + + if current - start >= 0.1: + return W.WEECHAT_RC_OK + + # We are done adding users, we can unhook now. + W.unhook(server.lazy_load_hook) + server.lazy_load_hook = None + + return W.WEECHAT_RC_OK + + +@utf8_decode +def matrix_timer_cb(server_name, remaining_calls): + server = SERVERS[server_name] + + current_time = time.time() + + if ( + (not server.connected) + and server.reconnect_time + and current_time >= (server.reconnect_time + server.reconnect_delay) + ): + server.reconnect() + return W.WEECHAT_RC_OK + + if not server.connected: + return W.WEECHAT_RC_OK + + # check lag, disconnect if it's too big + server.lag = server.client.lag * 1000 + server.lag_done = False + W.bar_item_update("lag") + + if server.lag > G.CONFIG.network.lag_reconnect * 1000: + server.disconnect() + return W.WEECHAT_RC_OK + + for i, message in enumerate(server.client.outgoing_to_device_messages): + if i >= 5: + break + + if message in server.to_device_sent: + continue + + server.to_device(message) + server.to_device_sent.append(message) + + if server.sync_time and current_time > server.sync_time: + timeout = 0 if server.transport_type == TransportType.HTTP else 30000 + sync_filter = { + "room": { + "timeline": {"limit": 5000}, + "state": {"lazy_load_members": True} + } + } + server.sync(timeout, sync_filter) + + if current_time > (server.user_gc_time + 3600): + server.garbage_collect_users() + + return W.WEECHAT_RC_OK + + +def create_default_server(config_file): + server = MatrixServer("matrix_org", config_file._ptr) + SERVERS[server.name] = server + + option = W.config_get(SCRIPT_NAME + ".server." + server.name + ".address") + W.config_option_set(option, "matrix.org", 1) + + return True + + +@utf8_decode +def send_cb(server_name, file_descriptor): + # type: (str, int) -> int + + server = SERVERS[server_name] + + if server.send_fd_hook: + W.unhook(server.send_fd_hook) + server.send_fd_hook = None + + if server.send_buffer: + server.try_send(server.send_buffer) + + return W.WEECHAT_RC_OK diff --git a/.weechat/python/matrix/uploads.py b/.weechat/python/matrix/uploads.py new file mode 100644 index 0000000..a88a91b --- /dev/null +++ b/.weechat/python/matrix/uploads.py @@ -0,0 +1,399 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2018, 2019 Damir Jelić +# +# Permission to use, copy, modify, and/or distribute this software for +# any purpose with or without fee is hereby granted, provided that the +# above copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER +# RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +"""Module implementing upload functionality.""" + +from __future__ import unicode_literals + +import attr +import time +import json +from typing import Dict, Any +from uuid import uuid1, UUID +from enum import Enum + +try: + from json.decoder import JSONDecodeError +except ImportError: + JSONDecodeError = ValueError # type: ignore + +from .globals import SCRIPT_NAME, SERVERS, W, UPLOADS +from .utf import utf8_decode +from matrix import globals as G +from nio import Api + + +class UploadState(Enum): + created = 0 + active = 1 + finished = 2 + error = 3 + aborted = 4 + + +@attr.s +class Proxy(object): + ptr = attr.ib(type=str) + + @property + def name(self): + return W.infolist_string(self.ptr, "name") + + @property + def address(self): + return W.infolist_string(self.ptr, "address") + + @property + def type(self): + return W.infolist_string(self.ptr, "type_string") + + @property + def port(self): + return str(W.infolist_integer(self.ptr, "port")) + + @property + def user(self): + return W.infolist_string(self.ptr, "username") + + @property + def password(self): + return W.infolist_string(self.ptr, "password") + + +@attr.s +class Upload(object): + """Class representing an upload to a matrix server.""" + + server_name = attr.ib(type=str) + server_address = attr.ib(type=str) + access_token = attr.ib(type=str) + room_id = attr.ib(type=str) + filepath = attr.ib(type=str) + encrypt = attr.ib(type=bool, default=False) + + done = 0 + total = 0 + + uuid = None + buffer = None + upload_hook = None + content_uri = None + file_name = None + mimetype = "?" + state = UploadState.created + + def __attrs_post_init__(self): + self.uuid = uuid1() + self.buffer = "" + + server = SERVERS[self.server_name] + + proxy_name = server.config.proxy + proxy = None + proxies_list = None + + if proxy_name: + proxies_list = W.infolist_get("proxy", "", proxy_name) + if proxies_list: + W.infolist_next(proxies_list) + proxy = Proxy(proxies_list) + + process_args = { + "arg1": self.filepath, + "arg2": self.server_address, + "arg3": self.access_token, + "buffer_flush": "1", + } + + arg_count = 3 + + if self.encrypt: + arg_count += 1 + process_args["arg{}".format(arg_count)] = "--encrypt" + + if not server.config.ssl_verify: + arg_count += 1 + process_args["arg{}".format(arg_count)] = "--insecure" + + if proxy: + arg_count += 1 + process_args["arg{}".format(arg_count)] = "--proxy-type" + arg_count += 1 + process_args["arg{}".format(arg_count)] = proxy.type + + arg_count += 1 + process_args["arg{}".format(arg_count)] = "--proxy-address" + arg_count += 1 + process_args["arg{}".format(arg_count)] = proxy.address + + arg_count += 1 + process_args["arg{}".format(arg_count)] = "--proxy-port" + arg_count += 1 + process_args["arg{}".format(arg_count)] = proxy.port + + if proxy.user: + arg_count += 1 + process_args["arg{}".format(arg_count)] = "--proxy-user" + arg_count += 1 + process_args["arg{}".format(arg_count)] = proxy.user + + if proxy.password: + arg_count += 1 + process_args["arg{}".format(arg_count)] = "--proxy-password" + arg_count += 1 + process_args["arg{}".format(arg_count)] = proxy.password + + self.upload_hook = W.hook_process_hashtable( + "matrix_upload", + process_args, + 0, + "upload_cb", + str(self.uuid) + ) + + if proxies_list: + W.infolist_free(proxies_list) + + def abort(self): + pass + + @property + def msgtype(self): + # type: () -> str + assert self.mimetype + return Api.mimetype_to_msgtype(self.mimetype) + + @property + def content(self): + # type: () -> Dict[Any, Any] + assert self.content_uri + + if self.encrypt: + content = { + "body": self.file_name, + "msgtype": self.msgtype, + "file": self.file_keys, + } + content["file"]["url"] = self.content_uri + content["file"]["mimetype"] = self.mimetype + + # TODO thumbnail if it's an image + + return content + + return { + "msgtype": self.msgtype, + "body": self.file_name, + "url": self.content_uri, + } + + @property + def render(self): + # type: () -> str + assert self.content_uri + + if self.encrypt: + http_url = Api.encrypted_mxc_to_plumb( + self.content_uri, + self.file_keys["key"]["k"], + self.file_keys["hashes"]["sha256"], + self.file_keys["iv"] + ) + url = http_url if http_url else self.content_uri + + description = "{}".format(self.file_name) + return ("{del_color}<{ncolor}{desc}{del_color}>{ncolor} " + "{del_color}[{ncolor}{url}{del_color}]{ncolor}").format( + del_color=W.color("chat_delimiters"), + ncolor=W.color("reset"), + desc=description, url=url) + + http_url = Api.mxc_to_http(self.content_uri) + description = ("/{}".format(self.file_name) if self.file_name + else "") + return "{url}{desc}".format(url=http_url, desc=description) + + +@attr.s +class UploadsBuffer(object): + """Weechat buffer showing the uploads for a server.""" + + _ptr = "" # type: str + _selected_line = 0 # type: int + uploads = UPLOADS + + def __attrs_post_init__(self): + self._ptr = W.buffer_new( + SCRIPT_NAME + ".uploads", + "", + "", + "", + "", + ) + W.buffer_set(self._ptr, "type", "free") + W.buffer_set(self._ptr, "title", "Upload list") + W.buffer_set(self._ptr, "key_bind_meta2-A", "/uploads up") + W.buffer_set(self._ptr, "key_bind_meta2-B", "/uploads down") + W.buffer_set(self._ptr, "localvar_set_type", "uploads") + + self.render() + + def move_line_up(self): + self._selected_line = max(self._selected_line - 1, 0) + self.render() + + def move_line_down(self): + self._selected_line = min( + self._selected_line + 1, + len(self.uploads) - 1 + ) + self.render() + + def display(self): + """Display the buffer.""" + W.buffer_set(self._ptr, "display", "1") + + def render(self): + """Render the new state of the upload buffer.""" + # This function is under the MIT license. + # Copyright (c) 2016 Vladimir Ignatev + def progress(count, total): + bar_len = 60 + + if total == 0: + bar = '-' * bar_len + return "[{}] {}%".format(bar, "?") + + filled_len = int(round(bar_len * count / float(total))) + percents = round(100.0 * count / float(total), 1) + bar = '=' * filled_len + '-' * (bar_len - filled_len) + + return "[{}] {}%".format(bar, percents) + + W.buffer_clear(self._ptr) + header = "{}{}{}{}{}{}{}{}".format( + W.color("green"), + "Actions (letter+enter):", + W.color("lightgreen"), + " [A] Accept", + " [C] Cancel", + " [R] Remove", + " [P] Purge finished", + " [Q] Close this buffer" + ) + W.prnt_y(self._ptr, 0, header) + + for line_number, upload in enumerate(self.uploads.values()): + line_color = "{},{}".format( + "white" if line_number == self._selected_line else "default", + "blue" if line_number == self._selected_line else "default", + ) + first_line = ("%s%s %-24s %s%s%s %s (%s.%s)" % ( + W.color(line_color), + "*** " if line_number == self._selected_line else " ", + upload.room_id, + "\"", + upload.filepath, + "\"", + upload.mimetype, + SCRIPT_NAME, + upload.server_name, + )) + W.prnt_y(self._ptr, (line_number * 2) + 2, first_line) + + status_color = "{},{}".format("green", "blue") + status = "{}{}{}".format( + W.color(status_color), + upload.state.name, + W.color(line_color) + ) + + second_line = ("{color}{prefix} {status} {progressbar} " + "{done} / {total}").format( + color=W.color(line_color), + prefix="*** " if line_number == self._selected_line else " ", + status=status, + progressbar=progress(upload.done, upload.total), + done=W.string_format_size(upload.done), + total=W.string_format_size(upload.total)) + + W.prnt_y(self._ptr, (line_number * 2) + 3, second_line) + + +def find_upload(uuid): + return UPLOADS.get(uuid, None) + + +def handle_child_message(upload, message): + if message["type"] == "progress": + upload.done = message["data"] + + elif message["type"] == "status": + if message["status"] == "started": + upload.state = UploadState.active + upload.total = message["total"] + upload.mimetype = message["mimetype"] + upload.file_name = message["file_name"] + + elif message["status"] == "done": + upload.state = UploadState.finished + upload.content_uri = message["url"] + upload.file_keys = message.get("file_keys", None) + + server = SERVERS.get(upload.server_name, None) + + if not server: + return + + server.room_send_upload(upload) + + elif message["status"] == "error": + upload.state = UploadState.error + + if G.CONFIG.upload_buffer: + G.CONFIG.upload_buffer.render() + + +@utf8_decode +def upload_cb(data, command, return_code, out, err): + upload = find_upload(UUID(data)) + + if not upload: + return W.WEECHAT_RC_OK + + if return_code == W.WEECHAT_HOOK_PROCESS_ERROR: + W.prnt("", "Error with command '%s'" % command) + return W.WEECHAT_RC_OK + + if err != "": + W.prnt("", "Error with command '%s'" % err) + upload.state = UploadState.error + + if out != "": + upload.buffer += out + messages = upload.buffer.split("\n") + upload.buffer = "" + + for m in messages: + try: + message = json.loads(m) + except (JSONDecodeError, TypeError): + upload.buffer += m + continue + + handle_child_message(upload, message) + + return W.WEECHAT_RC_OK diff --git a/.weechat/python/matrix/utf.py b/.weechat/python/matrix/utf.py new file mode 100644 index 0000000..4d71987 --- /dev/null +++ b/.weechat/python/matrix/utf.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2014-2016 Ryan Huber +# Copyright (c) 2015-2016 Tollef Fog Heen + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import unicode_literals + +import sys + +# pylint: disable=redefined-builtin +from builtins import bytes, str +from functools import wraps + +if sys.version_info.major == 3 and sys.version_info.minor >= 3: + from collections.abc import Iterable, Mapping +else: + from collections import Iterable, Mapping + +# These functions were written by Trygve Aaberge for wee-slack and are under a +# MIT License. +# More info can be found in the wee-slack repository under the commit: +# 5e1c7e593d70972afb9a55f29d13adaf145d0166, the repository can be found at: +# https://github.com/wee-slack/wee-slack + + +class WeechatWrapper(object): + def __init__(self, wrapped_class): + self.wrapped_class = wrapped_class + + # Helper method used to encode/decode method calls. + def wrap_for_utf8(self, method): + def hooked(*args, **kwargs): + result = method(*encode_to_utf8(args), **encode_to_utf8(kwargs)) + # Prevent wrapped_class from becoming unwrapped + if result == self.wrapped_class: + return self + return decode_from_utf8(result) + + return hooked + + # Encode and decode everything sent to/received from weechat. We use the + # unicode type internally in wee-slack, but has to send utf8 to weechat. + def __getattr__(self, attr): + orig_attr = self.wrapped_class.__getattribute__(attr) + if callable(orig_attr): + return self.wrap_for_utf8(orig_attr) + return decode_from_utf8(orig_attr) + + # Ensure all lines sent to weechat specify a prefix. For lines after the + # first, we want to disable the prefix, which is done by specifying a + # space. + def prnt_date_tags(self, buffer, date, tags, message): + message = message.replace("\n", "\n \t") + return self.wrap_for_utf8(self.wrapped_class.prnt_date_tags)( + buffer, date, tags, message + ) + + +def utf8_decode(function): + """ + Decode all arguments from byte strings to unicode strings. Use this for + functions called from outside of this script, e.g. callbacks from weechat. + """ + + @wraps(function) + def wrapper(*args, **kwargs): + + # Don't do anything if we're python 3 + if sys.hexversion >= 0x3000000: + return function(*args, **kwargs) + + return function(*decode_from_utf8(args), **decode_from_utf8(kwargs)) + + return wrapper + + +def decode_from_utf8(data): + if isinstance(data, bytes): + return data.decode("utf-8") + if isinstance(data, str): + return data + elif isinstance(data, Mapping): + return type(data)(map(decode_from_utf8, data.items())) + elif isinstance(data, Iterable): + return type(data)(map(decode_from_utf8, data)) + return data + + +def encode_to_utf8(data): + if isinstance(data, str): + return data.encode("utf-8") + if isinstance(data, bytes): + return data + elif isinstance(data, Mapping): + return type(data)(map(encode_to_utf8, data.items())) + elif isinstance(data, Iterable): + return type(data)(map(encode_to_utf8, data)) + return data diff --git a/.weechat/python/matrix/utils.py b/.weechat/python/matrix/utils.py new file mode 100644 index 0000000..6c29003 --- /dev/null +++ b/.weechat/python/matrix/utils.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2018, 2019 Damir Jelić +# Copyright © 2018, 2019 Denis Kasak +# +# Permission to use, copy, modify, and/or distribute this software for +# any purpose with or without fee is hereby granted, provided that the +# above copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER +# RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from __future__ import unicode_literals, division + +import time +from typing import Any, Dict, List + +from .globals import W + +if False: + from .server import MatrixServer + + +def key_from_value(dictionary, value): + # type: (Dict[str, Any], Any) -> str + return list(dictionary.keys())[list(dictionary.values()).index(value)] + + +def server_buffer_prnt(server, string): + # type: (MatrixServer, str) -> None + assert server.server_buffer + buffer = server.server_buffer + now = int(time.time()) + W.prnt_date_tags(buffer, now, "", string) + + +def tags_from_line_data(line_data): + # type: (str) -> List[str] + tags_count = W.hdata_get_var_array_size( + W.hdata_get("line_data"), line_data, "tags_array" + ) + + tags = [ + W.hdata_string( + W.hdata_get("line_data"), line_data, "%d|tags_array" % i + ) + for i in range(tags_count) + ] + + return tags + + +def create_server_buffer(server): + # type: (MatrixServer) -> None + buffer_name = "server.{}".format(server.name) + server.server_buffer = W.buffer_new( + buffer_name, "server_buffer_cb", server.name, "", "" + ) + + server_buffer_set_title(server) + W.buffer_set(server.server_buffer, "short_name", server.name) + W.buffer_set(server.server_buffer, "localvar_set_type", "server") + W.buffer_set( + server.server_buffer, "localvar_set_nick", server.config.username + ) + W.buffer_set(server.server_buffer, "localvar_set_server", server.name) + W.buffer_set(server.server_buffer, "localvar_set_channel", server.name) + + server.buffer_merge() + + +def server_buffer_set_title(server): + # type: (MatrixServer) -> None + if server.numeric_address: + ip_string = " ({address})".format(address=server.numeric_address) + else: + ip_string = "" + + title = ("Matrix: {address}:{port}{ip}").format( + address=server.address, port=server.config.port, ip=ip_string + ) + + W.buffer_set(server.server_buffer, "title", title) + + +def server_ts_to_weechat(timestamp): + # type: (float) -> int + date = int(timestamp / 1000) + return date + + +def strip_matrix_server(string): + # type: (str) -> str + return string.rsplit(":", 1)[0] + + +def shorten_sender(sender): + # type: (str) -> str + return strip_matrix_server(sender)[1:] + + +def string_strikethrough(string): + return "".join(["{}\u0336".format(c) for c in string]) + + +def string_color_and_reset(string, color): + """Color string with color, then reset all attributes.""" + + lines = string.split('\n') + lines = ("{}{}{}".format(W.color(color), line, W.color("reset")) + for line in lines) + return "\n".join(lines) + + +def string_color(string, color): + """Color string with color, then reset the color attribute.""" + + lines = string.split('\n') + lines = ("{}{}{}".format(W.color(color), line, W.color("resetcolor")) + for line in lines) + return "\n".join(lines) + + +def color_pair(color_fg, color_bg): + """Make a color pair from a pair of colors.""" + + if color_bg: + return "{},{}".format(color_fg, color_bg) + else: + return color_fg + + +def text_block(text, margin=0): + """ + Pad block of text with whitespace to form a regular block, optionally + adding a margin. + """ + + # add vertical margin + vertical_margin = margin // 2 + text = "{}{}{}".format( + "\n" * vertical_margin, + text, + "\n" * vertical_margin + ) + + lines = text.split("\n") + longest_len = max(len(l) for l in lines) + margin + + # pad block and add horizontal margin + text = "\n".join( + "{pre}{line}{post}".format( + pre=" " * margin, + line=l, + post=" " * (longest_len - len(l))) + for l in lines) + + return text + + +def colored_text_block(text, margin=0, color_pair=""): + """ Like text_block, but also colors it.""" + return string_color_and_reset(text_block(text, margin=margin), color_pair)