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 0000000..f4696f6 Binary files /dev/null and b/.weechat/python/matrix/__pycache__/__init__.cpython-37.pyc differ diff --git a/.weechat/python/matrix/__pycache__/bar_items.cpython-37.pyc b/.weechat/python/matrix/__pycache__/bar_items.cpython-37.pyc new file mode 100644 index 0000000..14fbddd Binary files /dev/null and b/.weechat/python/matrix/__pycache__/bar_items.cpython-37.pyc differ 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 0000000..b54fe60 Binary files /dev/null and b/.weechat/python/matrix/__pycache__/buffer.cpython-37.pyc differ 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 0000000..653177a Binary files /dev/null and b/.weechat/python/matrix/__pycache__/colors.cpython-37.pyc differ diff --git a/.weechat/python/matrix/__pycache__/commands.cpython-37.pyc b/.weechat/python/matrix/__pycache__/commands.cpython-37.pyc new file mode 100644 index 0000000..61a2275 Binary files /dev/null and b/.weechat/python/matrix/__pycache__/commands.cpython-37.pyc differ diff --git a/.weechat/python/matrix/__pycache__/completion.cpython-37.pyc b/.weechat/python/matrix/__pycache__/completion.cpython-37.pyc new file mode 100644 index 0000000..0c68381 Binary files /dev/null and b/.weechat/python/matrix/__pycache__/completion.cpython-37.pyc differ 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 0000000..61be389 Binary files /dev/null and b/.weechat/python/matrix/__pycache__/config.cpython-37.pyc differ diff --git a/.weechat/python/matrix/__pycache__/globals.cpython-37.pyc b/.weechat/python/matrix/__pycache__/globals.cpython-37.pyc new file mode 100644 index 0000000..5dd6a0a Binary files /dev/null and b/.weechat/python/matrix/__pycache__/globals.cpython-37.pyc differ 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 0000000..36b6b8a Binary files /dev/null and b/.weechat/python/matrix/__pycache__/server.cpython-37.pyc differ 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 0000000..698a71c Binary files /dev/null and b/.weechat/python/matrix/__pycache__/uploads.cpython-37.pyc differ 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 0000000..f6cc246 Binary files /dev/null and b/.weechat/python/matrix/__pycache__/utf.cpython-37.pyc differ 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 0000000..40a8548 Binary files /dev/null and b/.weechat/python/matrix/__pycache__/utils.cpython-37.pyc differ 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)