diff --git a/.gitignore b/.gitignore index 6d3c6dc..68aad69 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ .hypothesis/ .mypy_cache/ .pytest_cache/ +.ropeproject +.coverage diff --git a/.pylintrc b/.pylintrc index 3059c6d..0caa73e 100644 --- a/.pylintrc +++ b/.pylintrc @@ -130,7 +130,8 @@ disable=print-statement, bad-whitespace, too-few-public-methods, too-many-lines, - missing-docstring + missing-docstring, + bad-continuation, # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/Makefile b/Makefile index d023780..55b0c38 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install install-lib install-dir uninstall phony test +.PHONY: install install-lib install-dir uninstall phony test typecheck WEECHAT_HOME ?= $(HOME)/.weechat PREFIX ?= $(WEECHAT_HOME) @@ -26,3 +26,6 @@ $(DESTDIR)$(PREFIX)/python/matrix/%.py: matrix/%.py phony test: python3 -m pytest python2 -m pytest + +typecheck: + mypy -p matrix --ignore-missing-imports --warn-redundant-casts diff --git a/contrib/matrix_upload b/contrib/matrix_upload new file mode 100755 index 0000000..b4fd722 --- /dev/null +++ b/contrib/matrix_upload @@ -0,0 +1,245 @@ +#!/usr/bin/python3 -u +# Copyright © 2018 Damir Jelić +# +# Permission to use, copy, modify, and/or distribute this software for +# any purpose with or without fee is hereby granted, provided that the +# above copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER +# RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +import os +import json +import magic +import requests +import argparse +from urllib.parse import urlparse +import urllib3 + +from nio import Api, UploadResponse, UploadError +from json.decoder import JSONDecodeError + +urllib3.disable_warnings() + +mime = magic.Magic(mime=True) + + +class Upload(object): + def __init__(self, file, chunksize=1 << 13): + self.file = file + self.filename = os.path.basename(file) + self.chunksize = chunksize + self.totalsize = os.path.getsize(file) + self.mimetype = mime.from_file(file) + self.readsofar = 0 + + def send_progress(self): + message = { + "type": "progress", + "data": self.readsofar + } + to_stdout(message) + + def __iter__(self): + with open(self.filename, 'rb') as file: + while True: + data = file.read(self.chunksize) + + if not data: + break + + self.readsofar += len(data) + self.send_progress() + + yield data + + def __len__(self): + return self.totalsize + + +class IterableToFileAdapter(object): + def __init__(self, iterable): + self.iterator = iter(iterable) + self.length = len(iterable) + + def read(self, size=-1): + return next(self.iterator, b'') + + def __len__(self): + return self.length + + +def to_stdout(message): + print(json.dumps(message), flush=True) + + +def error(e): + message = { + "type": "status", + "status": "error", + "message": str(e) + } + to_stdout(message) + os.sys.exit() + + +def upload_process(args): + file_path = os.path.expanduser(args.file) + + try: + upload = Upload(file_path, 10) + except (FileNotFoundError, OSError, IOError) as e: + error(e) + + try: + url = urlparse(args.homeserver) + except ValueError as e: + error(e) + + upload_url = ("https://{}".format(args.homeserver) + if not url.scheme else args.homeserver) + _, api_path, _ = Api.upload(args.access_token, upload.filename) + upload_url += api_path + + headers = { + "Content-type": upload.mimetype, + } + + proxies = {} + + if args.proxy_address: + user = args.proxy_user or "" + + if args.proxy_password: + user += ":{}".format(args.proxy_password) + + if user: + user += "@" + + proxies = { + "https": "{}://{}{}:{}/".format( + args.proxy_type, + user, + args.proxy_address, + args.proxy_port + ) + } + + message = { + "type": "status", + "status": "started", + "total": upload.totalsize, + "mimetype": upload.mimetype, + "file_name": upload.filename, + } + to_stdout(message) + + session = requests.Session() + session.trust_env = False + + try: + r = session.post( + url=upload_url, + auth=None, + headers=headers, + data=IterableToFileAdapter(upload), + verify=(not args.insecure), + proxies=proxies + ) + except (requests.exceptions.RequestException, OSError) as e: + error(e) + + try: + json_response = json.loads(r.content) + except JSONDecodeError: + error(r.content) + + response = UploadResponse.from_dict(json_response) + + if isinstance(response, UploadError): + error(str(response)) + + message = { + "type": "status", + "status": "done", + "url": response.content_uri + } + + to_stdout(message) + + return 0 + + +def main(): + parser = argparse.ArgumentParser( + description="Download and decrypt matrix attachments" + ) + parser.add_argument("file", help="the file that will be uploaded") + parser.add_argument( + "homeserver", + type=str, + help="the address of the homeserver" + ) + parser.add_argument( + "access_token", + type=str, + help="the access token to use for the upload" + ) + parser.add_argument( + "--encrypt", + action="store_const", + const=True, + default=False, + help="encrypt the file before uploading it" + ) + parser.add_argument( + "--insecure", + action="store_const", + const=True, + default=False, + help="disable SSL certificate verification" + ) + parser.add_argument( + "--proxy-type", + choices=[ + "http", + "socks4", + "socks5" + ], + default="http", + help="type of the proxy that will be used to establish a connection" + ) + parser.add_argument( + "--proxy-address", + type=str, + help="address of the proxy that will be used to establish a connection" + ) + parser.add_argument( + "--proxy-port", + type=int, + default=8080, + help="port of the proxy that will be used to establish a connection" + ) + parser.add_argument( + "--proxy-user", + type=str, + help="user that will be used for authentication on the proxy" + ) + parser.add_argument( + "--proxy-password", + type=str, + help="password that will be used for authentication on the proxy" + ) + + args = parser.parse_args() + upload_process(args) + + +if __name__ == "__main__": + main() diff --git a/main.py b/main.py index d14ed1b..1790b75 100644 --- a/main.py +++ b/main.py @@ -17,69 +17,70 @@ from __future__ import unicode_literals +import os import socket import ssl -import time -import pprint -import OpenSSL.crypto as crypto import textwrap -from itertools import chain - # pylint: disable=redefined-builtin from builtins import str -from future.utils import bytes_to_native_str as n - +from itertools import chain # pylint: disable=unused-import -from typing import (List, Set, Dict, Tuple, Text, Optional, AnyStr, Deque, Any) +from typing import Any, AnyStr, Deque, Dict, List, Optional, Set, Text, Tuple -from matrix.colors import Formatted -from matrix.utf import utf8_decode -from matrix.http import HttpResponse -from matrix.api import MatrixSendMessage +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_cb, - matrix_command_join_cb, matrix_command_part_cb, - matrix_command_invite_cb, matrix_command_topic_cb, - matrix_command_pgup_cb, matrix_redact_command_cb, - matrix_command_buf_clear_cb, matrix_me_command_cb, - matrix_command_kick_cb) +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) +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) +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.server import ( - MatrixServer, - create_default_server, - send_cb, - matrix_timer_cb, - matrix_config_server_read_cb, - matrix_config_server_write_cb, - matrix_config_server_change_cb, -) - -from matrix.bar_items import (init_bar_items, matrix_bar_item_name, - matrix_bar_item_plugin, matrix_bar_item_lag, - matrix_bar_item_buffer_modes) - -from matrix.completion import ( - init_completion, matrix_command_completion_cb, - matrix_server_command_completion_cb, matrix_debug_completion_cb, - matrix_message_completion_cb, matrix_server_completion_cb) - -from matrix.utils import (key_from_value, server_buffer_prnt, prnt_debug, - server_buffer_set_title) - -from matrix.plugin_options import (DebugType, RedactType) - -from matrix.config import (matrix_config_init, matrix_config_read, - matrix_config_free, matrix_config_change_cb, - matrix_config_reload_cb) - -import matrix.globals - -from matrix.globals import W, SERVERS +from matrix.uploads import UploadsBuffer, upload_cb # yapf: disable -WEECHAT_SCRIPT_NAME = "matrix" # type: str +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 @@ -87,6 +88,9 @@ 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)) @@ -102,7 +106,8 @@ def print_certificate_info(buff, sock, cert): key_info = ("key info: {key_type} key {bits} bits, signed using " "{algo}").format( - key_type=key_type, bits=key_size, algo=signature_algorithm) + key_type=key_type, bits=key_size, + algo=n(signature_algorithm)) validity_info = (" Begins on: {before}\n" " Expires on: {after}").format( @@ -120,8 +125,8 @@ def print_certificate_info(buff, sock, cert): issuer = "issuer: {issuer}".format(issuer=issuer) fingerprints = (" SHA1: {}\n" - " SHA256: {}").format(sha1_fingerprint, - sha256_fingerprint) + " SHA256: {}").format(n(sha1_fingerprint), + n(sha256_fingerprint)) wrapper = textwrap.TextWrapper( initial_indent=" - ", subsequent_indent=" ") @@ -143,13 +148,6 @@ def print_certificate_info(buff, sock, cert): W.prnt(buff, message) -@utf8_decode -def matrix_event_timer_cb(server_name, remaining_calls): - server = SERVERS[server_name] - server.handle_events() - return W.WEECHAT_RC_OK - - def wrap_socket(server, file_descriptor): # type: (MatrixServer, int) -> None sock = None # type: socket.socket @@ -157,6 +155,10 @@ def wrap_socket(server, file_descriptor): 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 @@ -178,7 +180,7 @@ def wrap_socket(server, file_descriptor): ssl_socket = server.ssl_context.wrap_socket( sock, do_handshake_on_connect=False, - server_hostname=server.address) # type: ssl.SSLSocket + server_hostname=server.config.address) # type: ssl.SSLSocket server.socket = ssl_socket @@ -236,8 +238,11 @@ def try_ssl_handshake(server): return False - except ssl.SSLError as error: - str_error = error.reason if error.reason else "Unknown error" + 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( @@ -247,7 +252,7 @@ def try_ssl_handshake(server): server_buffer_prnt( server, ("{prefix}matrix: disconnecting from server..." - ).format(prefix=W.prefix("network"))) + ).format(prefix=W.prefix("network"))) server.disconnect() return False @@ -275,7 +280,7 @@ def receive_cb(server_name, file_descriptor): server_buffer_prnt( server, ("{prefix}matrix: disconnecting from server..." - ).format(prefix=W.prefix("network"))) + ).format(prefix=W.prefix("network"))) server.disconnect() @@ -288,54 +293,60 @@ def receive_cb(server_name, file_descriptor): prefix=W.prefix("network"))) server_buffer_prnt( server, ("{prefix}matrix: disconnecting from server..." - ).format(prefix=W.prefix("network"))) + ).format(prefix=W.prefix("network"))) server.disconnect() break - received = len(data) # type: int - parsed_bytes = server.http_parser.execute(data, received) + try: + server.client.receive(data) + except (RemoteTransportError, RemoteProtocolError) as e: + server.error(str(e)) + server.disconnect() + break - assert parsed_bytes == received + response = server.client.next_response(MAX_EVENTS) - if server.http_parser.is_partial_body(): - server.http_buffer.append(server.http_parser.recv_body()) + # Check if we need to send some data back + data_to_send = server.client.data_to_send() - if server.http_parser.is_message_complete(): - status = server.http_parser.get_status_code() - headers = server.http_parser.get_headers() - body = b"".join(server.http_buffer) + if data_to_send: + server.send(data_to_send) - message = server.receive_queue.popleft() - message.response = HttpResponse(status, headers, body) - receive_time = time.time() - server.lag = (receive_time - message.send_time) * 1000 - server.lag_done = True - W.bar_item_update("lag") - message.receive_time = receive_time - - prnt_debug(DebugType.MESSAGING, server, - ("{prefix}Received message of type {t} and " - "status {s}").format( - prefix=W.prefix("error"), - t=message.__class__.__name__, - s=status)) - - # Message done, reset the parser state. - server.reset_parser() - - server.handle_response(message) + 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) + 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() + + if negotiated_protocol is None: + negotiated_protocol = server.socket.selected_npn_protocol() + + if negotiated_protocol == "http/1.1": + server.transport_type = TransportType.HTTP + elif negotiated_protocol == "h2": + server.transport_type = TransportType.HTTP2 + + data = server.client.connect(server.transport_type) + server.send(data) server.login() @@ -356,71 +367,41 @@ def connect_cb(data, status, gnutls_rc, sock, error, ip_address): return W.WEECHAT_RC_OK elif status_value == W.WEECHAT_HOOK_CONNECT_ADDRESS_NOT_FOUND: - W.prnt( - server.server_buffer, - '{address} not found'.format(address=ip_address)) + server.error('{address} not found'.format(address=ip_address)) elif status_value == W.WEECHAT_HOOK_CONNECT_IP_ADDRESS_NOT_FOUND: - W.prnt(server.server_buffer, 'IP address not found') + server.error('IP address not found') elif status_value == W.WEECHAT_HOOK_CONNECT_CONNECTION_REFUSED: - W.prnt(server.server_buffer, 'Connection refused') + server.error('Connection refused') elif status_value == W.WEECHAT_HOOK_CONNECT_PROXY_ERROR: - W.prnt(server.server_buffer, - 'Proxy fails to establish connection to server') + server.error('Proxy fails to establish connection to server') elif status_value == W.WEECHAT_HOOK_CONNECT_LOCAL_HOSTNAME_ERROR: - W.prnt(server.server_buffer, 'Unable to set local hostname') + server.error('Unable to set local hostname') elif status_value == W.WEECHAT_HOOK_CONNECT_GNUTLS_INIT_ERROR: - W.prnt(server.server_buffer, 'TLS init error') + server.error('TLS init error') elif status_value == W.WEECHAT_HOOK_CONNECT_GNUTLS_HANDSHAKE_ERROR: - W.prnt(server.server_buffer, 'TLS Handshake failed') + server.error('TLS Handshake failed') elif status_value == W.WEECHAT_HOOK_CONNECT_MEMORY_ERROR: - W.prnt(server.server_buffer, 'Not enough memory') + server.error('Not enough memory') elif status_value == W.WEECHAT_HOOK_CONNECT_TIMEOUT: - W.prnt(server.server_buffer, 'Timeout') + server.error('Timeout') elif status_value == W.WEECHAT_HOOK_CONNECT_SOCKET_ERROR: - W.prnt(server.server_buffer, 'Unable to create socket') + server.error('Unable to create socket') else: - W.prnt( - server.server_buffer, - 'Unexpected error: {status}'.format(status=status_value)) + server.error('Unexpected error: {status}'.format(status=status_value)) server.disconnect(reconnect=True) return W.WEECHAT_RC_OK -@utf8_decode -def room_input_cb(server_name, buffer, input_data): - server = SERVERS[server_name] - - if not server.connected: - message = "{prefix}matrix: you are not connected to the server".format( - prefix=W.prefix("error")) - W.prnt(buffer, message) - return W.WEECHAT_RC_ERROR - - room_id = key_from_value(server.buffers, buffer) - room = server.rooms[room_id] - - if room.encrypted: - return W.WEECHAT_RC_OK - - formatted_data = Formatted.from_input_line(input_data) - - message = MatrixSendMessage( - server.client, room_id=room_id, formatted_message=formatted_data) - - server.send_or_queue(message) - return W.WEECHAT_RC_OK - - @utf8_decode def room_close_cb(data, buffer): W.prnt("", @@ -430,17 +411,123 @@ def room_close_cb(data, buffer): @utf8_decode def matrix_unload_cb(): - matrix_config_free(matrix.globals.CONFIG) - W.prnt("", "unloading") + 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.autoconnect: + 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 + + if __name__ == "__main__": if W.register(WEECHAT_SCRIPT_NAME, WEECHAT_SCRIPT_AUTHOR, WEECHAT_SCRIPT_VERSION, WEECHAT_SCRIPT_LICENSE, @@ -451,17 +538,22 @@ if __name__ == "__main__": "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. - matrix.globals.CONFIG = W.config_new("matrix", - "matrix_config_reload_cb", "") - matrix_config_init(matrix.globals.CONFIG) - matrix_config_read(matrix.globals.CONFIG) + G.CONFIG = MatrixConfig() + G.CONFIG.read() hook_commands() init_bar_items() init_completion() + W.hook_signal("buffer_switch", "buffer_switch_cb", "") + W.hook_signal("input_text_changed", "typing_notification_cb", "") + if not SERVERS: - create_default_server(matrix.globals.CONFIG) + create_default_server(G.CONFIG) autoconnect(SERVERS) diff --git a/matrix/_weechat.py b/matrix/_weechat.py index 7b17d8c..d8b8356 100644 --- a/matrix/_weechat.py +++ b/matrix/_weechat.py @@ -1,33 +1,106 @@ +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 - 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" - } - escape_codes = [] reset_code = "0" - def make_fg_color(color): - return "38;5;{}".format(color) + def make_fg_color(color_code): + return "38;5;{}".format(color_code) - def make_bg_color(color): - return "48;5;{}".format(color) + def make_bg_color(color_code): + return "48;5;{}".format(color_code) attributes = { "bold": "1", @@ -70,21 +143,21 @@ def color(color_name): stripped_color = fg_color.lstrip("*_|/!") - if stripped_color in weechat_base_colors: + if stripped_color in WEECHAT_BASE_COLORS: escape_codes.append( - make_fg_color(weechat_base_colors[stripped_color])) + make_fg_color(WEECHAT_BASE_COLORS[stripped_color])) elif stripped_color.isdigit(): num_color = int(stripped_color) - if num_color >= 0 and num_color < 256: + 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])) + 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 num_color >= 0 and num_color < 256: + if 0 <= num_color < 256: escape_codes.append(make_bg_color(bg_color)) escape_string = "\033[{}{}m".format(reset_code, ";".join(escape_codes)) @@ -92,7 +165,7 @@ def color(color_name): return escape_string -def prefix(prefix): +def prefix(prefix_string): prefix_to_symbol = { "error": "=!=", "network": "--", @@ -101,27 +174,75 @@ def prefix(prefix): "quit": "<--" } - if prefix in prefix_to_symbol: + if prefix_string in prefix_to_symbol: return prefix_to_symbol[prefix] return "" -def prnt(_, string): - print(string) +def prnt(_, message): + print(message) -def config_search_section(*args, **kwargs): +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(*args, **kwargs): +def config_new_option(*_, **__): pass -def mkdir_home(*args, **kwargs): +def mkdir_home(*_, **__): return True -def info_get(info, *args): +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/matrix/api.py b/matrix/api.py deleted file mode 100644 index f9e4a83..0000000 --- a/matrix/api.py +++ /dev/null @@ -1,512 +0,0 @@ -# -*- coding: utf-8 -*- - -# 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 -from builtins import str - -import time -import json -from enum import Enum, unique -from functools import partial - -try: - from json.decoder import JSONDecodeError -except ImportError: - JSONDecodeError = ValueError - -try: - from urllib import quote, urlencode - from urlparse import urlparse -except ImportError: - from urllib.parse import quote, urlencode, urlparse - -from matrix.http import RequestType, HttpRequest -import matrix.events as MatrixEvents - -MATRIX_API_PATH = "/_matrix/client/r0" # type: str - - -class MatrixClient: - - def __init__( - self, - host, # type: str - access_token="", # type: str - user_agent="" # type: str - ): - # type: (...) -> None - self.host = host - self.user_agent = user_agent - self.access_token = access_token - self.txn_id = 0 # type: int - - def _get_txn_id(self): - txn_id = self.txn_id - self.txn_id = self.txn_id + 1 - return txn_id - - def login(self, user, password, device_name="", device_id=""): - # type (str, str, str) -> HttpRequest - path = ("{api}/login").format(api=MATRIX_API_PATH) - - post_data = { - "type": "m.login.password", - "user": user, - "password": password - } - - if device_id: - post_data["device_id"] = device_id - - if device_name: - post_data["initial_device_display_name"] = device_name - - return HttpRequest(RequestType.POST, self.host, path, post_data) - - def sync(self, next_batch="", sync_filter=None): - # type: (str, Dict[Any, Any]) -> HttpRequest - assert self.access_token - - query_parameters = {"access_token": self.access_token} - - if sync_filter: - query_parameters["filter"] = json.dumps( - sync_filter, separators=(",", ":")) - - if next_batch: - query_parameters["since"] = next_batch - - path = ("{api}/sync?{query_params}").format( - api=MATRIX_API_PATH, query_params=urlencode(query_parameters)) - - return HttpRequest(RequestType.GET, self.host, path) - - def room_send_message(self, - room_id, - message_type, - content, - formatted_content=None): - # type: (str, str, str) -> HttpRequest - query_parameters = {"access_token": self.access_token} - - body = {"msgtype": message_type, "body": content} - - if formatted_content: - body["format"] = "org.matrix.custom.html" - body["formatted_body"] = formatted_content - - path = ("{api}/rooms/{room}/send/m.room.message/" - "{tx_id}?{query_parameters}").format( - api=MATRIX_API_PATH, - room=quote(room_id), - tx_id=quote(str(self._get_txn_id())), - query_parameters=urlencode(query_parameters)) - - return HttpRequest(RequestType.PUT, self.host, path, body) - - def room_topic(self, room_id, topic): - # type: (str, str) -> HttpRequest - query_parameters = {"access_token": self.access_token} - - content = {"topic": topic} - - path = ("{api}/rooms/{room}/state/m.room.topic?" - "{query_parameters}").format( - api=MATRIX_API_PATH, - room=quote(room_id), - query_parameters=urlencode(query_parameters)) - - return HttpRequest(RequestType.PUT, self.host, path, content) - - def room_redact(self, room_id, event_id, reason=None): - # type: (str, str, str) -> HttpRequest - query_parameters = {"access_token": self.access_token} - content = {} - - if reason: - content["reason"] = reason - - path = ("{api}/rooms/{room}/redact/{event_id}/{tx_id}?" - "{query_parameters}").format( - api=MATRIX_API_PATH, - room=quote(room_id), - event_id=quote(event_id), - tx_id=quote(str(self._get_txn_id())), - query_parameters=urlencode(query_parameters)) - - return HttpRequest(RequestType.PUT, self.host, path, content) - - def room_get_messages(self, - room_id, - start_token, - end_token="", - limit=10, - direction='b'): - query_parameters = { - "access_token": self.access_token, - "from": start_token, - "dir": direction, - "limit": str(limit) - } - - if end_token: - query_parameters["to"] = end_token - - path = ("{api}/rooms/{room}/messages?{query_parameters}").format( - api=MATRIX_API_PATH, - room=quote(room_id), - query_parameters=urlencode(query_parameters)) - - return HttpRequest(RequestType.GET, self.host, path) - - def room_join(self, room_id): - query_parameters = {"access_token": self.access_token} - - path = ("{api}/join/{room_id}?" - "{query_parameters}").format( - api=MATRIX_API_PATH, - room_id=quote(room_id), - query_parameters=urlencode(query_parameters)) - - return HttpRequest(RequestType.POST, self.host, path) - - def room_leave(self, room_id): - query_parameters = {"access_token": self.access_token} - - path = ("{api}/rooms/{room_id}/leave?" - "{query_parameters}").format( - api=MATRIX_API_PATH, - room_id=quote(room_id), - query_parameters=urlencode(query_parameters)) - - return HttpRequest(RequestType.POST, self.host, path) - - def room_invite(self, room_id, user_id): - query_parameters = {"access_token": self.access_token} - - content = {"user_id": user_id} - - path = ("{api}/rooms/{room_id}/invite?" - "{query_parameters}").format( - api=MATRIX_API_PATH, - room_id=quote(room_id), - query_parameters=urlencode(query_parameters)) - - return HttpRequest(RequestType.POST, self.host, path, content) - - def room_kick(self, room_id, user_id, reason=None): - query_parameters = {"access_token": self.access_token} - - content = {"user_id": user_id} - - if reason: - content["reason"] = reason - - path = ("{api}/rooms/{room_id}/kick?" - "{query_parameters}").format( - api=MATRIX_API_PATH, - room_id=quote(room_id), - query_parameters=urlencode(query_parameters)) - - h = HttpRequest(RequestType.POST, self.host, path, content) - return h - - def mxc_to_http(self, mxc): - # type: (str) -> str - url = urlparse(mxc) - - if url.scheme != "mxc": - return None - - if not url.netloc or not url.path: - return None - - http_url = ("https://{host}/_matrix/media/r0/download/" - "{server_name}{mediaId}").format( - host=self.host, - server_name=url.netloc, - mediaId=url.path) - - return http_url - - -class MatrixMessage(): - - def __init__( - self, - request_func, # type: Callable[[...], HttpRequest] - func_args, - ): - # type: (...) -> None - # yapf: disable - - self.request = None # type: HttpRequest - self.response = None # type: HttpResponse - self.decoded_response = None # type: Dict[Any, Any] - - self.creation_time = time.time() # type: float - self.send_time = None # type: float - self.receive_time = None # type: float - self.event = None - - self.request = request_func(**func_args) - # yapf: enable - - def decode_body(self, server): - try: - self.decoded_response = json.loads( - self.response.body, encoding='utf-8') - return (True, None) - except Exception as error: - return (False, error) - - def _decode(self, server, object_hook): - try: - parsed_dict = json.loads( - self.response.body, - encoding='utf-8', - ) - - self.event = object_hook(parsed_dict) - - return (True, None) - - except JSONDecodeError as error: - return (False, error) - - -class MatrixLoginMessage(MatrixMessage): - - def __init__(self, client, user, password, device_name, device_id=None): - data = {"user": user, "password": password, "device_name": device_name} - - if device_id: - data["device_id"] = device_id - - MatrixMessage.__init__(self, client.login, data) - - def decode_body(self, server): - object_hook = partial(MatrixEvents.MatrixLoginEvent.from_dict, server) - - return self._decode(server, object_hook) - - -class MatrixSyncMessage(MatrixMessage): - - def __init__(self, client, next_batch=None, limit=None): - data = {} - - if next_batch: - data["next_batch"] = next_batch - - if limit: - data["sync_filter"] = {"room": {"timeline": {"limit": limit}}} - - MatrixMessage.__init__(self, client.sync, data) - - def decode_body(self, server): - object_hook = partial(MatrixEvents.MatrixSyncEvent.from_dict, server) - - return self._decode(server, object_hook) - - -class MatrixSendMessage(MatrixMessage): - - def __init__(self, - client, - room_id, - formatted_message, - message_type="m.text"): - self.room_id = room_id - self.formatted_message = formatted_message - - data = { - "room_id": self.room_id, - "message_type": message_type, - "content": self.formatted_message.to_plain() - } - - if self.formatted_message.is_formatted(): - data["formatted_content"] = self.formatted_message.to_html() - - MatrixMessage.__init__(self, client.room_send_message, data) - - def decode_body(self, server): - object_hook = partial( - MatrixEvents.MatrixSendEvent.from_dict, - server, - self.room_id, - self.formatted_message, - ) - - return self._decode(server, object_hook) - - -class MatrixEmoteMessage(MatrixSendMessage): - - def __init__(self, client, room_id, formatted_message): - MatrixSendMessage.__init__(self, client, room_id, formatted_message, - "m.emote") - - def decode_body(self, server): - object_hook = partial( - MatrixEvents.MatrixEmoteEvent.from_dict, - server, - self.room_id, - self.formatted_message, - ) - - return self._decode(server, object_hook) - - -class MatrixTopicMessage(MatrixMessage): - - def __init__(self, client, room_id, topic): - self.room_id = room_id - self.topic = topic - - data = {"room_id": self.room_id, "topic": self.topic} - - MatrixMessage.__init__(self, client.room_topic, data) - - def decode_body(self, server): - object_hook = partial( - MatrixEvents.MatrixTopicEvent.from_dict, - server, - self.room_id, - self.topic, - ) - - return self._decode(server, object_hook) - - -class MatrixRedactMessage(MatrixMessage): - - def __init__(self, client, room_id, event_id, reason=None): - self.room_id = room_id - self.event_id = event_id - self.reason = reason - - data = {"room_id": self.room_id, "event_id": self.event_id} - - if reason: - data["reason"] = reason - - MatrixMessage.__init__(self, client.room_redact, data) - - def decode_body(self, server): - object_hook = partial( - MatrixEvents.MatrixRedactEvent.from_dict, - server, - self.room_id, - self.reason, - ) - - return self._decode(server, object_hook) - - -class MatrixBacklogMessage(MatrixMessage): - - def __init__(self, client, room_id, token, limit): - self.room_id = room_id - - data = { - "room_id": self.room_id, - "start_token": token, - "direction": "b", - "limit": limit - } - - MatrixMessage.__init__(self, client.room_get_messages, data) - - def decode_body(self, server): - object_hook = partial(MatrixEvents.MatrixBacklogEvent.from_dict, server, - self.room_id) - - return self._decode(server, object_hook) - - -class MatrixJoinMessage(MatrixMessage): - - def __init__(self, client, room_id): - self.room_id = room_id - - data = {"room_id": self.room_id} - - MatrixMessage.__init__(self, client.room_join, data) - - def decode_body(self, server): - object_hook = partial(MatrixEvents.MatrixJoinEvent.from_dict, server, - self.room_id) - - return self._decode(server, object_hook) - - -class MatrixPartMessage(MatrixMessage): - - def __init__(self, client, room_id): - self.room_id = room_id - - data = {"room_id": self.room_id} - - MatrixMessage.__init__(self, client.room_leave, data) - - def decode_body(self, server): - object_hook = partial(MatrixEvents.MatrixPartEvent.from_dict, server, - self.room_id) - - return self._decode(server, object_hook) - - -class MatrixInviteMessage(MatrixMessage): - - def __init__(self, client, room_id, user_id): - self.room_id = room_id - self.user_id = user_id - - data = {"room_id": self.room_id, "user_id": self.user_id} - - MatrixMessage.__init__(self, client.room_invite, data) - - def decode_body(self, server): - object_hook = partial(MatrixEvents.MatrixInviteEvent.from_dict, server, - self.room_id, self.user_id) - - return self._decode(server, object_hook) - - -class MatrixKickMessage(MatrixMessage): - - def __init__(self, client, room_id, user_id, reason=None): - self.room_id = room_id - self.user_id = user_id - self.reason = reason - - data = {"room_id": self.room_id, - "user_id": self.user_id, - "reason": reason} - - MatrixMessage.__init__(self, client.room_kick, data) - - def decode_body(self, server): - object_hook = partial( - MatrixEvents.MatrixKickEvent.from_dict, - server, - self.room_id, - self.user_id, - self.reason) - - return self._decode(server, object_hook) diff --git a/matrix/bar_items.py b/matrix/bar_items.py index 1f3cfff..d95f447 100644 --- a/matrix/bar_items.py +++ b/matrix/bar_items.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2018 Damir Jelić +# 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 @@ -15,24 +15,22 @@ # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. from __future__ import unicode_literals -from builtins import str -from matrix.utf import utf8_decode -from matrix.utils import key_from_value - -from matrix.globals import W, SERVERS +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): + 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) + name=server.name, + ) return "" @@ -42,25 +40,31 @@ 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") + color = ( + "status_name_ssl" + if server.ssl_context.check_hostname + else "status_name" + ) - room_id = key_from_value(server.buffers, buffer) - - room = server.rooms[room_id] + room_buffer = server.find_room_from_ptr(buffer) + room = room_buffer.room return "{color}{name}".format( - color=W.color(color), - name=room.display_name(server.user_id)) + color=W.color(color), name=room.display_name + ) - elif buffer == server.server_buffer: - color = ("status_name_ssl" - if server.ssl_context.check_hostname else "status_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=server.name, + ) return "" @@ -69,9 +73,8 @@ def matrix_bar_item_name(data, item, window, buffer, extra_info): 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 >= 500: + 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") @@ -80,7 +83,8 @@ def matrix_bar_item_lag(data, item, window, buffer, extra_info): lag_string = "Lag: {color}{lag}{ncolor}".format( lag=lag.format((server.lag / 1000)), color=color, - ncolor=W.color("reset")) + ncolor=W.color("reset"), + ) return lag_string return "" @@ -92,23 +96,94 @@ 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_id = key_from_value(server.buffers, buffer) - room = server.rooms[room_id] + room_buffer = server.find_room_from_ptr(buffer) + room = room_buffer.room modes = [] if room.encrypted: - modes.append("🔐") + modes.append(G.CONFIG.look.encrypted_room_sign) - if room.backlog_pending: - modes.append("⏳") + 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 + for server in SERVERS.values(): + if buffer in server.buffers.values(): + room_buffer = server.find_room_from_ptr(buffer) + room = room_buffer.room + return str(room.member_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/matrix/buffer.py b/matrix/buffer.py new file mode 100644 index 0000000..8225bd3 --- /dev/null +++ b/matrix/buffer.py @@ -0,0 +1,1688 @@ +# -*- 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 +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, +) + +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") + except OlmTrustError as e: + m = ("Untrusted devices found in room: {}".format(e)) + room_buffer.error(m) + pass + + 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) + + 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, prev_batch): + self.room = room + 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 + + 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 + ) + + try: + _, room_domain = room.room_id.split(":", 1) + W.buffer_set( + self.weechat_buffer._ptr, + "localvar_set_domain", + room_domain + ) + except ValueError: + pass + + 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_")): + if user.display_name: + short_name = user.display_name[0:50] + elif user.user_id.startswith("@freenode_"): + short_name = shorten_sender(user.user_id[9:]) + + # 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 + + 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 + + lines = self.weechat_buffer.find_lines( + partial(predicate, event.redacts) + ) + + # No line to redact, return early + if not lines: + return + + # TODO multiple lines can contain a single matrix ID, we need to redact + # them all + line = lines[0] + + censor = self.find_nick(event.sender) + message = line.message + tags = line.tags + + 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, + ) + + 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 + + message = " ".join(s for s in [new_message, redaction_msg] if s) + + tags.append("matrix_redacted") + + line.message = 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) + 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 + ) + 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 + + 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/matrix/colors.py b/matrix/colors.py index 18a2809..f789f56 100644 --- a/matrix/colors.py +++ b/matrix/colors.py @@ -2,7 +2,8 @@ # Copyright © 2008 Nicholas Marriott # Copyright © 2016 Avi Halachmi -# Copyright © 2018 Damir Jelić +# 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 @@ -18,39 +19,63 @@ from __future__ import unicode_literals +import html +import re +import textwrap + # pylint: disable=redefined-builtin from builtins import str from collections import namedtuple -from matrix.globals import W -from matrix.utils import string_strikethrough +from typing import List -import textwrap 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 -import html -from html.entities import name2codepoint -FormattedString = namedtuple('FormattedString', ['text', 'attributes']) +class FormattedString: + __slots__ = ("text", "attributes") -quote_wrapper = textwrap.TextWrapper( - initial_indent="> ", subsequent_indent="> ") + def __init__(self, text, attributes): + self.attributes = DEFAULT_ATTRIBUTES.copy() + self.attributes.update(attributes) + self.text = text -class Formatted(): - +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_ATRIBUTES: + if string.attributes != DEFAULT_ATTRIBUTES: return True return False @@ -64,35 +89,52 @@ class Formatted(): """ text = "" # type: str substrings = [] # type: List[FormattedString] - attributes = DEFAULT_ATRIBUTES.copy() + attributes = DEFAULT_ATTRIBUTES.copy() i = 0 while i < len(line): # Bold - if line[i] == "\x02": + 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 bold - elif line[i] == "*": - if attributes["italic"] and not line[i-1].isspace(): + # 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())) + substrings.append( + FormattedString(text, attributes.copy()) + ) text = "" attributes["italic"] = not attributes["italic"] i = i + 1 continue - elif attributes["italic"] and line[i-1].isspace(): + 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(): + elif i + 1 < len(line) and line[i + 1].isspace(): text = text + line[i] i = i + 1 continue @@ -109,7 +151,7 @@ class Formatted(): i = i + 1 # Color - elif line[i] == "\x03": + elif line[i] == "\x03" and not attributes["code"]: if text: substrings.append(FormattedString(text, attributes.copy())) text = "" @@ -147,15 +189,16 @@ class Formatted(): else: attributes["bgcolor"] = None # Reset - elif line[i] == "\x0F": + elif line[i] == "\x0F" and not attributes["code"]: if text: substrings.append(FormattedString(text, attributes.copy())) text = "" # Reset all the attributes - attributes = DEFAULT_ATRIBUTES.copy() + attributes = DEFAULT_ATTRIBUTES.copy() i = i + 1 + # Italic - elif line[i] == "\x1D": + elif line[i] == "\x1D" and not attributes["code"]: if text: substrings.append(FormattedString(text, attributes.copy())) text = "" @@ -163,7 +206,7 @@ class Formatted(): i = i + 1 # Underline - elif line[i] == "\x1F": + elif line[i] == "\x1F" and not attributes["code"]: if text: substrings.append(FormattedString(text, attributes.copy())) text = "" @@ -175,7 +218,7 @@ class Formatted(): text = text + line[i] i = i + 1 - substrings.append(FormattedString(text, attributes)) + substrings.append(FormattedString(text, DEFAULT_ATTRIBUTES.copy())) return cls(substrings) @classmethod @@ -190,36 +233,60 @@ class Formatted(): def add_attribute(string, name, value): if name == "bold" and value: return "{bold_on}{text}{bold_off}".format( - bold_on="", text=string, bold_off="") - elif name == "italic" and value: + bold_on="", text=string, bold_off="" + ) + if name == "italic" and value: return "{italic_on}{text}{italic_off}".format( - italic_on="", text=string, italic_off="") - elif name == "underline" and value: + italic_on="", text=string, italic_off="" + ) + if name == "underline" and value: return "{underline_on}{text}{underline_off}".format( - underline_on="", text=string, underline_off="") - elif name == "strikethrough" and value: + underline_on="", text=string, underline_off="" + ) + if name == "strikethrough" and value: return "{strike_on}{text}{strike_off}".format( - strike_on="", text=string, strike_off="") - elif name == "quote" and value: + strike_on="", text=string, strike_off="" + ) + if name == "quote" and value: return "{quote_on}{text}{quote_off}".format( quote_on="
", text=string, - quote_off="
") - elif name == "fgcolor" and value: + quote_off="", + ) + if name == "code" and value: + return "{code_on}{text}{code_off}".format( + code_on="", text=string, code_off="" + ) + if name == "fgcolor" and value: return "{color_on}{text}{color_off}".format( color_on="".format( - color=color_weechat_to_html(value)), + color=color_weechat_to_html(value) + ), text=string, - color_off="") + color_off="", + ) return string def format_string(formatted_string): text = formatted_string.text - attributes = formatted_string.attributes + 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") + else: + for key, value in attributes.items(): + text = add_attribute(text, key, value) - for key, value in attributes.items(): - text = add_attribute(text, key, value) return text html_string = map(format_string, self.substrings) @@ -228,7 +295,7 @@ class Formatted(): # TODO do we want at least some formatting using unicode # (strikethrough, quotes)? def to_plain(self): - # type: (List[FormattedString]) -> str + # type: () -> str def strip_atribute(string, _, __): return string @@ -245,44 +312,91 @@ class Formatted(): def to_weechat(self): # TODO BG COLOR - def add_attribute(string, name, value): - if name == "bold" and value: + 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" and value: + 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" and value: + 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" and value: + 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 + ) - elif name == "quote" and value: - return quote_wrapper.fill(string.replace("\n", "")) + margin = G.CONFIG.look.code_block_margin - elif name == "fgcolor" and value: + 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" and value: + 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")) - - return string + color_off=W.color("resetcolor"), + ) + else: + return string def format_string(formatted_string): text = formatted_string.text @@ -291,28 +405,68 @@ class Formatted(): # 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.pop('strikethrough') + 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(): - text = add_attribute(text, key, value) - return text + 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) - return "".join(weechat_strings).rstrip("\n") + + # 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_ATRIBUTES = { +DEFAULT_ATTRIBUTES = { "bold": False, "italic": False, "underline": False, "strikethrough": False, + "preformatted": False, "quote": False, + "code": None, "fgcolor": None, - "bgcolor": None + "bgcolor": None, } @@ -323,7 +477,7 @@ class MatrixHtmlParser(HTMLParser): HTMLParser.__init__(self) self.text = "" # type: str self.substrings = [] # type: List[FormattedString] - self.attributes = DEFAULT_ATRIBUTES.copy() + self.attributes = DEFAULT_ATTRIBUTES.copy() def unescape(self, text): """Shim to unescape HTML in both Python 2 and 3. @@ -357,11 +511,33 @@ class MatrixHtmlParser(HTMLParser): 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_ATRIBUTES.copy()) + self.add_substring(self.text, DEFAULT_ATTRIBUTES.copy()) self.text = "" elif tag == "font": for key, value in attrs: @@ -387,8 +563,18 @@ class MatrixHtmlParser(HTMLParser): 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()) @@ -515,7 +701,7 @@ def color_line_to_weechat(color_string): "96": "250", "97": "254", "98": "231", - "99": "default" + "99": "default", } assert color_string in line_colors @@ -523,20 +709,20 @@ def color_line_to_weechat(color_string): return line_colors[color_string] -# The functions colour_dist_sq(), colour_to_6cube(), and colour_find_rgb +# 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 colour_dist_sq(R, G, B, r, g, b): +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 colour_to_6cube(v): +def color_to_6cube(v): # pylint: disable=invalid-name # type: (int) -> int if v < 48: @@ -546,15 +732,15 @@ def colour_to_6cube(v): return (v - 35) // 40 -def colour_find_rgb(r, g, b): +def color_find_rgb(r, g, b): # type: (int, int, int) -> int - """Convert an RGB triplet to the xterm(1) 256 colour palette. + """Convert an RGB triplet to the xterm(1) 256 color palette. - xterm provides a 6x6x6 colour cube (16 - 231) and 24 greys (232 - 255). - We map our RGB colour to the closest in the cube, also work out the + 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 colours (they + 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). @@ -563,16 +749,16 @@ def colour_find_rgb(r, g, b): q2c = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff] # Map RGB to 6x6x6 cube. - qr = colour_to_6cube(r) - qg = colour_to_6cube(g) - qb = colour_to_6cube(b) + 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 colour exactly, return early. - if (cr == r and cg == g and cb == b): + # 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). @@ -585,10 +771,10 @@ def colour_find_rgb(r, g, b): grey = 8 + (10 * grey_idx) - # Is grey or 6x6x6 colour closest? - d = colour_dist_sq(cr, cg, cb, r, g, b) + # Is grey or 6x6x6 color closest? + d = color_dist_sq(cr, cg, cb, r, g, b) - if colour_dist_sq(grey, grey, grey, r, g, b) < d: + if color_dist_sq(grey, grey, grey, r, g, b) < d: idx = 232 + grey_idx else: idx = 16 + (36 * qr) + (6 * qg) + qb @@ -622,12 +808,12 @@ def color_html_to_weechat(color): try: rgb_color = webcolors.html5_parse_legacy_color(color) except ValueError: - return None + return "" if rgb_color in weechat_basic_colors: return weechat_basic_colors[rgb_color] - return str(colour_find_rgb(*rgb_color)) + return str(color_find_rgb(*rgb_color)) def color_weechat_to_html(color): @@ -913,5 +1099,50 @@ def color_weechat_to_html(color): # yapf: enable if color in weechat_basic_colors: return hex_colors[weechat_basic_colors[color]] - else: - return hex_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/matrix/commands.py b/matrix/commands.py index dd7a440..5063c01 100644 --- a/matrix/commands.py +++ b/matrix/commands.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- -# Copyright © 2018 Damir Jelić +# 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 @@ -15,74 +16,254 @@ # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. from __future__ import unicode_literals -from builtins import str - +import argparse import re -import time +from builtins import str +from future.moves.itertools import zip_longest +from collections import defaultdict -import matrix.globals -from matrix.globals import W, OPTIONS, SERVERS +from . import globals as G +from .colors import Formatted +from .globals import SERVERS, W, UPLOADS +from .server import MatrixServer +from .utf import utf8_decode +from .utils import key_from_value, tags_from_line_data +from .uploads import UploadsBuffer, Upload -from matrix.utf import utf8_decode -from matrix.api import (MatrixTopicMessage, MatrixRedactMessage, - MatrixBacklogMessage, MatrixJoinMessage, - MatrixPartMessage, MatrixInviteMessage, - MatrixEmoteMessage, MatrixKickMessage) -from matrix.utils import key_from_value, tags_from_line_data -from matrix.plugin_options import DebugType -from matrix.server import MatrixServer -from matrix.colors import Formatted + +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" + ]) + 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="?") + + 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', + "matrix", + "Matrix chat protocol command", # Synopsis - ('server add [:] ||' - 'server delete|list|listfull ||' - 'connect ||' - 'disconnect ||' - 'reconnect ||' - 'debug ||' - 'help '), + ( + "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\n' - ' help: show detailed command help\n\n' - ' debug: enable or disable debugging\n\n' - 'Use /matrix help [command] to find out more.\n'), + ( + " 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) ||' - 'debug %(matrix_debug_types) ||' - 'help %(matrix_commands)'), + ( + "server %(matrix_server_commands)|%* ||" + "connect %(matrix_servers) ||" + "disconnect %(matrix_servers) ||" + "reconnect %(matrix_servers) ||" + "help %(matrix_commands)" + ), # Function name - 'matrix_command_cb', - '') + "matrix_command_cb", + "", + ) W.hook_command( # Command name and short description - 'redact', - 'redact messages', + "redact", + "redact messages", # Synopsis ('[:""] []'), # Description - ("message-number: number of message to redact (starting from 1 for\n" - " the last message received, counting up)\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"), + ( + "message-number: number of message to redact " + "(starting from 1 for\n" + " the last message received, counting up)\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)'), + ("%(matrix_messages)"), # Function name - 'matrix_redact_command_cb', - '') + "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 @@ -94,59 +275,547 @@ def hook_commands(): ("message: message to send"), # Completions "", - # Function name + # Callback "matrix_me_command_cb", - "") + "", + ) - W.hook_command_run('/topic', 'matrix_command_topic_cb', '') - W.hook_command_run('/buffer clear', 'matrix_command_buf_clear_cb', '') - W.hook_command_run('/join', 'matrix_command_join_cb', '') - W.hook_command_run('/part', 'matrix_command_part_cb', '') - W.hook_command_run('/invite', 'matrix_command_invite_cb', '') - W.hook_command_run('/kick', 'matrix_command_kick_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", + "", + ) - if OPTIONS.enable_backlog: + 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|private|unverified|verified ||" + "blacklist ||" + "unverify ||" + "verify "), + # 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\n" + "Examples:" + "\n /olm verify @example:example.com *" + "\n /olm info all example*" + ), + # Completions + ('info all|blacklisted|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)'), + # 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_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): + fp_key = partition_key(fp_key) + message = (" - Device ID: {device_color}{device_id}{ncolor}\n" + " - Device key: {key_color}{fp_key}{ncolor}").format( + device_color=W.color("chat_channel"), + device_id=device_id, + ncolor=W.color("reset"), + 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 + )) + + 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 + ) + + +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 + )) + 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 + ) + + +@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 == "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) + 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")) + 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_id = key_from_value(server.buffers, buffer) + room_buffer = server.find_room_from_ptr(buffer) if not args: return W.WEECHAT_RC_OK formatted_data = Formatted.from_input_line(args) - message = MatrixEmoteMessage( - server.client, - room_id=room_id, - formatted_message=formatted_data) - - server.send_or_queue(message) + server.room_send_message(room_buffer, formatted_data, "m.emote") return W.WEECHAT_RC_OK - elif buffer == server.server_buffer: - message = ("{prefix}matrix: command \"me\" must be " - "executed on a Matrix channel buffer" - ).format(prefix=W.prefix("error")) + 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 = server.rooms[room_id] + room_buffer = server.find_room_from_id(room_id) + room = room_buffer.room - if room.backlog_pending: + if room_buffer.backlog_pending: return prev_batch = room.prev_batch @@ -154,39 +823,31 @@ def matrix_fetch_old_messages(server, room_id): if not prev_batch: return - message = MatrixBacklogMessage( - server.client, - room_id=room_id, - token=prev_batch, - limit=OPTIONS.backlog_limit) - room.backlog_pending = True - W.bar_item_update("buffer_modes") - - server.send_or_queue(message) - - 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) + prefix=W.prefix("error"), server=server_name + ) W.prnt("", message) return False return True def hook_page_up(): - OPTIONS.page_up_hook = W.hook_command_run('/window page_up', - 'matrix_command_pgup_cb', '') + 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_id = key_from_value(server.buffers, buffer) - server.rooms[room_id].prev_batch = server.next_batch + room_buffer = server.find_room_from_ptr(buffer) + room_buffer.room.prev_batch = server.next_batch return W.WEECHAT_RC_OK @@ -205,11 +866,12 @@ def matrix_command_pgup_cb(data, buffer, command): window = W.window_search_with_buffer(buffer) first_line_displayed = bool( - W.window_get_integer(window, "first_line_displayed")) + W.window_get_integer(window, "first_line_displayed") + ) if first_line_displayed: room_id = key_from_value(server.buffers, buffer) - matrix_fetch_old_messages(server, room_id) + server.room_get_messages(room_id) return W.WEECHAT_RC_OK @@ -217,153 +879,228 @@ def matrix_command_pgup_cb(data, buffer, command): @utf8_decode -def matrix_command_join_cb(data, buffer, command): - - def join(server, args): - split_args = args.split(" ", 1) - - # TODO handle join for non public rooms - if len(split_args) != 2: - message = ("{prefix}Error with command \"/join\" (help on " - "command: /help join)").format(prefix=W.prefix("error")) - W.prnt("", message) - return - - _, room_id = split_args - message = MatrixJoinMessage(server.client, room_id=room_id) - server.send_or_queue(message) +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(): - join(server, command) - return W.WEECHAT_RC_OK_EAT - elif buffer == server.server_buffer: - join(server, command) - return W.WEECHAT_RC_OK_EAT + 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_command_part_cb(data, buffer, command): - - def part(server, buffer, args): - rooms = [] - - split_args = args.split(" ", 1) - - if len(split_args) == 1: - if buffer == server.server_buffer: - message = ( - "{prefix}Error with command \"/part\" (help on " - "command: /help part)").format(prefix=W.prefix("error")) - W.prnt("", message) - return - - rooms = [key_from_value(server.buffers, buffer)] - - else: - _, rooms = split_args - rooms = rooms.split(" ") - - for room_id in rooms: - message = MatrixPartMessage(server.client, room_id=room_id) - server.send_or_queue(message) +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(): - part(server, buffer, command) - return W.WEECHAT_RC_OK_EAT - elif buffer == server.server_buffer: - part(server, buffer, command) - return W.WEECHAT_RC_OK_EAT + 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_command_invite_cb(data, buffer, command): - - def invite(server, buf, args): - split_args = args.split(" ", 1) - - # TODO handle join for non public rooms - if len(split_args) != 2: - message = ( - "{prefix}Error with command \"/invite\" (help on " - "command: /help invite)").format(prefix=W.prefix("error")) - W.prnt("", message) - return - - _, invitee = split_args - room_id = key_from_value(server.buffers, buf) - - message = MatrixInviteMessage( - server.client, room_id=room_id, user_id=invitee) - server.send_or_queue(message) +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 in server.buffers.values(): - invite(server, buffer, command) - return W.WEECHAT_RC_OK_EAT + 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_command_kick_cb(data, buffer, command): - - def kick(server, buf, args): - split_args = args.split(" ", 1)[1:] - - if (len(split_args) < 1 or - split_args[0].startswith("#") and len(split_args) < 2): - error_msg = ( - '{prefix}Error with command "/kick" (help on ' - 'command: /help kick)').format(prefix=W.prefix("error")) - W.prnt("", error_msg) - return - - if split_args[0].startswith("#"): - assert len(split_args) >= 2 - room_id = split_args[0] - kicked_user = split_args[1] - reason = split_args[2:] or None - else: - room_id = key_from_value(server.buffers, buf) - kicked_user = split_args[0] - reason = split_args[1:] or None - - message = MatrixKickMessage( - server.client, room_id=room_id, user_id=kicked_user, reason=reason) - server.send_or_queue(message) +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 in server.buffers.values(): - kick(server, buffer, command) - return W.WEECHAT_RC_OK_EAT + 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 + + if room_buffer.room.encrypted: + room_buffer.error("Uploading to encrypted rooms is " + "not yet implemented") + return W.WEECHAT_RC_OK + + 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 def event_id_from_line(buf, target_number): - # type: (weechat.buffer, int) -> str - own_lines = W.hdata_pointer(W.hdata_get('buffer'), buf, 'own_lines') + # type: (str, int) -> str + own_lines = W.hdata_pointer(W.hdata_get("buffer"), buf, "own_lines") if own_lines: - line = W.hdata_pointer(W.hdata_get('lines'), own_lines, 'last_line') + line = W.hdata_pointer(W.hdata_get("lines"), own_lines, "last_line") line_number = 1 while line: - line_data = W.hdata_pointer(W.hdata_get('line'), line, 'data') + line_data = W.hdata_pointer(W.hdata_get("line"), line, "data") if line_data: tags = tags_from_line_data(line_data) # Only count non redacted user messages - if ("matrix_message" in tags and - 'matrix_redacted' not in tags and - "matrix_new_redacted" not in tags): + if ( + "matrix_message" in tags + and "matrix_redacted" not in tags + and "matrix_new_redacted" not in tags + ): if line_number == target_number: for tag in tags: @@ -373,7 +1110,7 @@ def event_id_from_line(buf, target_number): line_number += 1 - line = W.hdata_move(W.hdata_get('line'), line, -1) + line = W.hdata_move(W.hdata_get("line"), line, -1) return "" @@ -382,13 +1119,15 @@ def event_id_from_line(buf, target_number): def matrix_redact_command_cb(data, buffer, args): for server in SERVERS.values(): if buffer in server.buffers.values(): - room_id = key_from_value(server.buffers, buffer) + room_buffer = server.find_room_from_ptr(buffer) matches = re.match(r"(\d+)(:\".*\")? ?(.*)?", args) if not matches: - message = ("{prefix}matrix: Invalid command arguments (see /help redact)" - ).format(prefix=W.prefix("error")) + message = ( + "{prefix}matrix: Invalid command " + "arguments (see /help redact)" + ).format(prefix=W.prefix("error")) W.prnt("", message) return W.WEECHAT_RC_ERROR @@ -398,72 +1137,34 @@ def matrix_redact_command_cb(data, buffer, args): event_id = event_id_from_line(buffer, line) if not event_id: - message = ("{prefix}matrix: No such message with number " - "{number} found").format( - prefix=W.prefix("error"), number=line) + message = ( + "{prefix}matrix: No such message with number " + "{number} found" + ).format(prefix=W.prefix("error"), number=line) W.prnt("", message) return W.WEECHAT_RC_OK - message = MatrixRedactMessage( - server.client, - room_id=room_id, - event_id=event_id, - reason=reason) - server.send_or_queue(message) + server.room_send_redaction(room_buffer, event_id, reason) return W.WEECHAT_RC_OK - elif buffer == server.server_buffer: - message = ("{prefix}matrix: command \"redact\" must be " - "executed on a Matrix channel buffer" - ).format(prefix=W.prefix("error")) + 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_debug(args): - if not args: - message = ("{prefix}matrix: Too few arguments for command " - "\"/matrix debug\" (see /matrix help debug)" - ).format(prefix=W.prefix("error")) - W.prnt("", message) - return - - def toggle_debug(debug_type): - if debug_type in OPTIONS.debug: - message = ("{prefix}matrix: Disabling matrix {t} " - "debugging.").format( - prefix=W.prefix("error"), t=debug_type) - W.prnt("", message) - OPTIONS.debug.remove(debug_type) - else: - message = ("{prefix}matrix: Enabling matrix {t} " - "debugging.").format( - prefix=W.prefix("error"), t=debug_type) - W.prnt("", message) - OPTIONS.debug.append(debug_type) - - for command in args: - if command == "network": - toggle_debug(DebugType.NETWORK) - elif command == "messaging": - toggle_debug(DebugType.MESSAGING) - elif command == "timing": - toggle_debug(DebugType.TIMING) - else: - message = ("{prefix}matrix: Unknown matrix debug " - "type \"{t}\".").format( - prefix=W.prefix("error"), t=command) - W.prnt("", message) - - 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")) + message = ( + "{prefix}matrix: Too few arguments for command " + '"/matrix help" (see /matrix help help)' + ).format(prefix=W.prefix("error")) W.prnt("", message) return @@ -471,101 +1172,104 @@ def matrix_command_help(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")) + 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")) + 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")) + 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 del matrix").format( - delimiter_color=W.color("chat_delimiters"), - cmd_color=W.color("chat_buffer"), - ncolor=W.color("reset")) + 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 del 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")) - - elif command == "debug": - message = ("{delimiter_color}[{ncolor}matrix{delimiter_color}] " - "{ncolor}{cmd_color}/debug{ncolor} " - " [...]" - "\n\n" - "enable/disable degugging for a Matrix subsystem" - "\n\n" - "debug-type: a Matrix debug type, one of messaging, " - "timing, network").format( - delimiter_color=W.color("chat_delimiters"), - cmd_color=W.color("chat_buffer"), - ncolor=W.color("reset")) + 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) + message = ( + '{prefix}matrix: No help available, "{command}" ' + "is not a matrix command" + ).format(prefix=W.prefix("error"), command=command) W.prnt("", "") W.prnt("", message) @@ -574,7 +1278,6 @@ def matrix_command_help(args): def matrix_server_command_listfull(args): - def get_value_string(value, default_value): if value == default_value: if not value: @@ -584,7 +1287,8 @@ def matrix_server_command_listfull(args): value_string = "{color}{value}{ncolor}".format( color=W.color("chat_value"), value=value, - ncolor=W.color("reset")) + ncolor=W.color("reset"), + ) return value_string @@ -602,18 +1306,21 @@ def matrix_server_command_listfull(args): 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")) + 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.options["autoconnect"] + option = server.config._option_ptrs["autoconnect"] default_value = W.config_string_default(option) value = W.config_string(option) @@ -622,7 +1329,7 @@ def matrix_server_command_listfull(args): W.prnt("", message) - option = server.options["address"] + option = server.config._option_ptrs["address"] default_value = W.config_string_default(option) value = W.config_string(option) @@ -631,7 +1338,7 @@ def matrix_server_command_listfull(args): W.prnt("", message) - option = server.options["port"] + option = server.config._option_ptrs["port"] default_value = str(W.config_integer_default(option)) value = str(W.config_integer(option)) @@ -640,7 +1347,7 @@ def matrix_server_command_listfull(args): W.prnt("", message) - option = server.options["username"] + option = server.config._option_ptrs["username"] default_value = W.config_string_default(option) value = W.config_string(option) @@ -649,13 +1356,13 @@ def matrix_server_command_listfull(args): W.prnt("", message) - option = server.options["password"] + option = server.config._option_ptrs["password"] value = W.config_string(option) if value: value = "(hidden)" - value_string = get_value_string(value, '') + value_string = get_value_string(value, "") message = " password . . : {value}".format(value=value_string) W.prnt("", message) @@ -667,14 +1374,17 @@ def matrix_server_command_delete(args): 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) + 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 @@ -684,14 +1394,16 @@ def matrix_server_command_delete(args): if server.server_buffer: W.buffer_close(server.server_buffer) - for option in server.options.values(): + 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")) + 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 @@ -701,36 +1413,41 @@ def matrix_server_command_delete(args): 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")) + message = ( + "{prefix}matrix: Too few arguments for command " + '"/matrix server add" (see /matrix help server)' + ).format(prefix=W.prefix("error")) W.prnt("", message) return - elif len(args) > 4: - message = ("{prefix}matrix: Too many arguments for command " - "\"/matrix server add\" (see /matrix help server)" - ).format(prefix=W.prefix("error")) + 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.options.values(): + 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")) + 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, matrix.globals.CONFIG) + server = MatrixServer(server_name, G.CONFIG._ptr) SERVERS[server.name] = server if len(args) >= 2: @@ -739,33 +1456,43 @@ def matrix_server_command_add(args): except ValueError: host, port = args[1], None - return_code = W.config_option_set(server.options["address"], host, 1) + 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")) + 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.options["port"], port, 1) + 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")) + 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 @@ -773,17 +1500,22 @@ def matrix_server_command_add(args): if len(args) >= 3: user = args[2] - return_code = W.config_option_set(server.options["username"], user, 1) + 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")) + 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 @@ -792,58 +1524,67 @@ def matrix_server_command_add(args): if len(args) == 4: password = args[3] - return_code = W.config_option_set(server.options["password"], password, - 1) + 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")) + 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")) + 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)) + 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': + if command == "list": list_servers(args) - elif command == 'listfull': + elif command == "listfull": matrix_server_command_listfull(args) - elif command == 'add': + elif command == "add": matrix_server_command_add(args) - elif command == 'delete': + 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) + 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): @@ -860,108 +1601,44 @@ def matrix_command_cb(data, buffer, args): server.access_token = "" server.disconnect(reconnect=False) - split_args = list(filter(bool, args.split(' '))) + 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")) + 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': + if command == "connect": connect_server(args) - elif command == 'disconnect': + elif command == "disconnect": disconnect_server(args) - elif command == 'reconnect': + elif command == "reconnect": disconnect_server(args) connect_server(args) - elif command == 'server': + 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': + elif command == "help": matrix_command_help(args) - elif command == 'debug': - matrix_command_debug(args) - else: - message = ("{prefix}matrix: Error: unknown matrix command, " - "\"{command}\" (type /help matrix for help)").format( - prefix=W.prefix("error"), command=command) + 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_command_topic_cb(data, buffer, command): - for server in SERVERS.values(): - if buffer in server.buffers.values(): - topic = None - room_id = key_from_value(server.buffers, buffer) - split_command = command.split(' ', 1) - - if len(split_command) == 2: - topic = split_command[1] - - if not topic: - room = server.rooms[room_id] - if not room.topic: - return W.WEECHAT_RC_OK - - if room.is_named(): - message = ('{prefix}Topic for {color}{room}{ncolor} is ' - '"{topic}"').format( - prefix=W.prefix("network"), - color=W.color("chat_buffer"), - ncolor=W.color("reset"), - room=room.named_room_name(), - topic=room.topic) - else: - message = ('{prefix}Topic is "{topic}"').format( - prefix=W.prefix("network"), - topic=room.topic) - - date = int(time.time()) - topic_date = room.topic_date.strftime("%a, %d %b %Y " - "%H:%M:%S") - - tags = "matrix_topic,log1" - W.prnt_date_tags(buffer, date, tags, message) - - # TODO the nick should be colored - - # TODO we should use the display name as well as - # the user name here - message = ("{prefix}Topic set by {author} on " - "{date}").format( - prefix=W.prefix("network"), - author=room.topic_author, - date=topic_date) - W.prnt_date_tags(buffer, date, tags, message) - - return W.WEECHAT_RC_OK_EAT - - message = MatrixTopicMessage( - server.client, room_id=room_id, topic=topic) - server.send_or_queue(message) - - return W.WEECHAT_RC_OK_EAT - - elif buffer == server.server_buffer: - message = ("{prefix}matrix: command \"topic\" must be " - "executed on a Matrix channel buffer" - ).format(prefix=W.prefix("error")) - W.prnt(buffer, message) - return W.WEECHAT_RC_OK_EAT - - return W.WEECHAT_RC_OK diff --git a/matrix/completion.py b/matrix/completion.py index 8212f41..d8939aa 100644 --- a/matrix/completion.py +++ b/matrix/completion.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2018 Damir Jelić +# 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 @@ -16,29 +16,33 @@ from __future__ import unicode_literals +from matrix.globals import SERVERS, W from matrix.utf import utf8_decode -from matrix.globals import W, SERVERS, OPTIONS 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) + 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): +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'] + 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) + W.hook_completion_list_add( + completion, command, 0, W.WEECHAT_LIST_POS_SORT + ) if len(args) == 1: complete_commands() @@ -47,11 +51,11 @@ def matrix_server_command_completion_cb(data, completion_item, buffer, if args[1] not in commands: complete_commands() else: - if args[1] == 'delete' or args[1] == 'listfull': + 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[1] == "delete" or args[1] == "listfull": if args[2] not in SERVERS: add_servers_to_completion(completion) @@ -67,69 +71,255 @@ def matrix_server_completion_cb(data, completion_item, buffer, completion): @utf8_decode def matrix_command_completion_cb(data, completion_item, buffer, completion): for command in [ - "connect", "disconnect", "reconnect", "server", "help", "debug" + "connect", + "disconnect", + "reconnect", + "server", + "help", + "debug", ]: - W.hook_completion_list_add(completion, command, 0, - W.WEECHAT_LIST_POS_SORT) + 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) + 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): - own_lines = W.hdata_pointer(W.hdata_get('buffer'), buffer, 'own_lines') + own_lines = W.hdata_pointer(W.hdata_get("buffer"), buffer, "own_lines") if own_lines: - line = W.hdata_pointer(W.hdata_get('lines'), own_lines, 'last_line') + line = W.hdata_pointer(W.hdata_get("lines"), own_lines, "last_line") line_number = 1 while line: - line_data = W.hdata_pointer(W.hdata_get('line'), line, 'data') + line_data = W.hdata_pointer(W.hdata_get("line"), line, "data") if line_data: message = W.hdata_string( - W.hdata_get('line_data'), line_data, 'message') + W.hdata_get("line_data"), line_data, "message" + ) tags = tags_from_line_data(line_data) # Only add non redacted user messages to the completion - if (message and 'matrix_message' in tags and - 'matrix_redacted' not in tags): + if ( + message + and "matrix_message" in tags + and "matrix_redacted" not in tags + ): - if len(message) > OPTIONS.redaction_comp_len + 2: - message = (message[:OPTIONS.redaction_comp_len] + '..') + if len(message) > REDACTION_COMP_LEN + 2: + message = message[:REDACTION_COMP_LEN] + ".." - item = ("{number}:\"{message}\"").format( - number=line_number, message=message) + item = ('{number}:"{message}"').format( + number=line_number, message=message + ) - W.hook_completion_list_add(completion, item, 0, - W.WEECHAT_LIST_POS_END) + W.hook_completion_list_add( + completion, item, 0, W.WEECHAT_LIST_POS_END + ) line_number += 1 - line = W.hdata_move(W.hdata_get('line'), line, -1) + line = W.hdata_move(W.hdata_get("line"), line, -1) + + 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 def init_completion(): - W.hook_completion("matrix_server_commands", "Matrix server completion", - "matrix_server_command_completion_cb", "") + 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_servers", + "Matrix server completion", + "matrix_server_completion_cb", + "", + ) - W.hook_completion("matrix_commands", "Matrix command completion", - "matrix_command_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_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( + "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", + "", + ) diff --git a/matrix/config.py b/matrix/config.py index 4bc8850..281058e 100644 --- a/matrix/config.py +++ b/matrix/config.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- -# Copyright © 2018 Damir Jelić +# 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 @@ -14,15 +15,112 @@ # 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 +"""weechat-matrix Configuration module. -from matrix.plugin_options import (Option, RedactType, ServerBufferType) +This module contains abstractions on top of weechats configuration files and +the main script configuration class. -import matrix.globals -from matrix.globals import W, OPTIONS, SERVERS +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 matrix.utils import key_from_value, server_buffer_merge -from matrix.commands import hook_page_up + +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 @@ -30,105 +128,648 @@ 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 matrix_config_change_cb(data, option): - option_name = key_from_value(OPTIONS.options, option) +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""" - if option_name == "redactions": - OPTIONS.redaction_type = RedactType(W.config_integer(option)) + for server in SERVERS.values(): + server.buffer_merge() + return 1 - elif option_name == "server_buffer": - OPTIONS.look_server_buf = ServerBufferType(W.config_integer(option)) - for server in SERVERS.values(): - if server.server_buffer: - server_buffer_merge(server.server_buffer) - elif option_name == "max_initial_sync_events": - OPTIONS.sync_limit = W.config_integer(option) +@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 - elif option_name == "max_backlog_sync_events": - OPTIONS.backlog_limit = W.config_integer(option) - elif option_name == "fetch_backlog_on_pgup": - OPTIONS.enable_backlog = W.config_boolean(option) +@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 - if OPTIONS.enable_backlog: - if not OPTIONS.page_up_hook: - hook_page_up(matrix.globals.CONFIG) - else: - if OPTIONS.page_up_hook: - W.unhook(OPTIONS.page_up_hook) - OPTIONS.page_up_hook = None + +@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 matrix_config_init(config_file): - look_options = [ - Option("redactions", "integer", "strikethrough|notice|delete", 0, 0, - "strikethrough", - ("Only notice redactions, strike through or delete " - "redacted messages")), - Option("server_buffer", "integer", - "merge_with_core|merge_without_core|independent", 0, 0, - "merge_with_core", "Merge server buffers") - ] +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 - 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")) - ] - - def add_global_options(section, options): - for option in options: - OPTIONS.options[option.name] = W.config_new_option( - config_file, section, option.name, option.type, - option.description, option.string_values, option.min, - option.max, option.value, option.value, 0, "", "", - "matrix_config_change_cb", "", "", "") - - section = W.config_new_section(config_file, "color", 0, 0, "", "", "", "", - "", "", "", "", "", "") - - # TODO color options - - section = W.config_new_section(config_file, "look", 0, 0, "", "", "", "", - "", "", "", "", "", "") - - add_global_options(section, look_options) - - section = W.config_new_section(config_file, "network", 0, 0, "", "", "", "", - "", "", "", "", "", "") - - add_global_options(section, network_options) - - W.config_new_section( - config_file, "server", 0, 0, "matrix_config_server_read_cb", "", - "matrix_config_server_write_cb", "", "", "", "", "", "", "") - - return config_file + return logbook.ERROR -def matrix_config_read(config): - # type: (str) -> bool - return_code = W.config_read(config) - if return_code == W.WEECHAT_CONFIG_READ_OK: - return True - elif return_code == W.WEECHAT_CONFIG_READ_MEMORY_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 - elif return_code == W.WEECHAT_CONFIG_READ_FILE_NOT_FOUND: - return True - return False -def matrix_config_free(config): - for section in ["network", "look", "color", "server"]: - section_pointer = W.config_search_section(config, section) - W.config_section_free_options(section_pointer) - W.config_section_free(section_pointer) +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 = {} - W.config_free(config) + 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 + + 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"), + ), + ] + + 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"), + ), + ] + + 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 free(self): + section_ptr = W.config_search_section(self._ptr, "server") + W.config_section_free(section_ptr) + super().free() diff --git a/matrix/events.py b/matrix/events.py deleted file mode 100644 index b70141d..0000000 --- a/matrix/events.py +++ /dev/null @@ -1,453 +0,0 @@ -# -*- coding: utf-8 -*- - -# 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 -from builtins import str - -from collections import deque -from functools import partial -from operator import itemgetter - -from matrix.globals import W -from matrix.utils import (tags_for_message, sanitize_id, sanitize_token, - sanitize_text, tags_from_line_data) -from matrix.rooms import (matrix_create_room_buffer, RoomInfo, RoomMessageText, - RoomMessageEvent, RoomRedactedMessageEvent, - RoomMessageEmote) - - -class MatrixEvent(): - - def __init__(self, server): - self.server = server - - def execute(self): - pass - - -class MatrixErrorEvent(MatrixEvent): - - def __init__(self, server, error_message, fatal=False): - self.error_message = error_message - self.fatal = fatal - MatrixEvent.__init__(self, server) - - def execute(self): - message = ("{prefix}matrix: {error}").format( - prefix=W.prefix("error"), error=self.error_message) - - W.prnt(self.server.server_buffer, message) - - if self.fatal: - self.server.disconnect(reconnect=False) - - @classmethod - def from_dict(cls, server, error_prefix, fatal, parsed_dict): - try: - message = "{prefix}: {error}".format( - prefix=error_prefix, error=sanitize_text(parsed_dict["error"])) - return cls(server, message, fatal=fatal) - except KeyError: - return cls( - server, ("{prefix}: Invalid JSON response " - "from server.").format(prefix=error_prefix), - fatal=fatal) - - -class MatrixLoginEvent(MatrixEvent): - - def __init__(self, server, user_id, device_id, access_token): - self.user_id = user_id - self.access_token = access_token - self.device_id = device_id - MatrixEvent.__init__(self, server) - - def execute(self): - self.server.access_token = self.access_token - self.server.user_id = self.user_id - self.server.client.access_token = self.access_token - self.server.device_id = self.device_id - self.server.save_device_id() - - message = "{prefix}matrix: Logged in as {user}".format( - prefix=W.prefix("network"), user=self.user_id) - - W.prnt(self.server.server_buffer, message) - self.server.sync() - - @classmethod - def from_dict(cls, server, parsed_dict): - try: - return cls(server, sanitize_id(parsed_dict["user_id"]), - sanitize_id(parsed_dict["device_id"]), - sanitize_token(parsed_dict["access_token"])) - except (KeyError, TypeError, ValueError): - return MatrixErrorEvent.from_dict(server, "Error logging in", True, - parsed_dict) - - -class MatrixSendEvent(MatrixEvent): - - def __init__(self, server, room_id, message): - self.room_id = room_id - self.message = message - MatrixEvent.__init__(self, server) - - def execute(self): - tags = [ - "matrix_message", "notify_none", "no_highlight", "self_msg", "log1" - ] - - buff = self.server.buffers[self.room_id] - room = self.server.rooms[self.room_id] - - self.message.execute(self.server, room, buff, tags) - - @classmethod - def from_dict(cls, server, room_id, message, parsed_dict): - try: - event_id = sanitize_id(parsed_dict["event_id"]) - sender = server.user_id - age = 0 - plain_message = message.to_plain() - formatted_message = message - - message = RoomMessageText(event_id, sender, age, plain_message, - formatted_message) - - return cls(server, room_id, message) - except (KeyError, TypeError, ValueError): - return MatrixErrorEvent.from_dict(server, "Error sending message", - False, parsed_dict) - - -class MatrixEmoteEvent(MatrixSendEvent): - - @classmethod - def from_dict(cls, server, room_id, message, parsed_dict): - try: - event_id = sanitize_id(parsed_dict["event_id"]) - sender = server.user_id - age = 0 - plain_message = message.to_plain() - formatted_message = message - - message = RoomMessageEmote(event_id, sender, age, plain_message, - formatted_message) - - return cls(server, room_id, message) - except (KeyError, TypeError, ValueError): - return MatrixErrorEvent.from_dict(server, "Error sending message", - False, parsed_dict) - - -class MatrixTopicEvent(MatrixEvent): - - def __init__(self, server, room_id, event_id, topic): - self.room_id = room_id - self.topic = topic - self.event_id = event_id - MatrixEvent.__init__(self, server) - - @classmethod - def from_dict(cls, server, room_id, topic, parsed_dict): - try: - return cls(server, room_id, sanitize_id(parsed_dict["event_id"]), - topic) - except (KeyError, TypeError, ValueError): - return MatrixErrorEvent.from_dict(server, "Error setting topic", - False, parsed_dict) - - -class MatrixRedactEvent(MatrixEvent): - - def __init__(self, server, room_id, event_id, reason): - self.room_id = room_id - self.topic = reason - self.event_id = event_id - MatrixEvent.__init__(self, server) - - @classmethod - def from_dict(cls, server, room_id, reason, parsed_dict): - try: - return cls(server, room_id, sanitize_id(parsed_dict["event_id"]), - reason) - except (KeyError, TypeError, ValueError): - return MatrixErrorEvent.from_dict(server, "Error redacting message", - False, parsed_dict) - - -class MatrixJoinEvent(MatrixEvent): - - def __init__(self, server, room, room_id): - self.room = room - self.room_id = room_id - MatrixEvent.__init__(self, server) - - @classmethod - def from_dict(cls, server, room_id, parsed_dict): - try: - return cls( - server, - room_id, - sanitize_id(parsed_dict["room_id"]), - ) - except (KeyError, TypeError, ValueError): - return MatrixErrorEvent.from_dict(server, "Error joining room", - False, parsed_dict) - - -class MatrixPartEvent(MatrixEvent): - - def __init__(self, server, room_id): - self.room_id = room_id - MatrixEvent.__init__(self, server) - - @classmethod - def from_dict(cls, server, room_id, parsed_dict): - try: - if parsed_dict == {}: - return cls(server, room_id) - - raise KeyError - except KeyError: - return MatrixErrorEvent.from_dict(server, "Error leaving room", - False, parsed_dict) - - -class MatrixInviteEvent(MatrixEvent): - - def __init__(self, server, room_id, user_id): - self.room_id = room_id - self.user_id = user_id - MatrixEvent.__init__(self, server) - - @classmethod - def from_dict(cls, server, room_id, user_id, parsed_dict): - try: - if parsed_dict == {}: - return cls(server, room_id, user_id) - - raise KeyError - except KeyError: - return MatrixErrorEvent.from_dict(server, "Error inviting user", - False, parsed_dict) - - -class MatrixKickEvent(MatrixEvent): - - def __init__(self, server, room_id, user_id, reason): - self.room_id = room_id - self.user_id = user_id - self.reason = reason - MatrixEvent.__init__(self, server) - - @classmethod - def from_dict(cls, server, room_id, user_id, reason, parsed_dict): - try: - if parsed_dict == {}: - return cls(server, room_id, user_id, reason) - - raise KeyError - except KeyError: - return MatrixErrorEvent.from_dict(server, "Error kicking user", - False, parsed_dict) - - -class MatrixBacklogEvent(MatrixEvent): - - def __init__(self, server, room_id, end_token, events): - self.room_id = room_id - self.end_token = end_token - self.events = events - MatrixEvent.__init__(self, server) - - @staticmethod - def _room_event_from_dict(room_id, event_dict): - if room_id != event_dict["room_id"]: - raise ValueError - - if "redacted_by" in event_dict["unsigned"]: - return RoomRedactedMessageEvent.from_dict(event_dict) - - return RoomMessageEvent.from_dict(event_dict) - - @staticmethod - def buffer_sort_messages(buff): - lines = [] - - own_lines = W.hdata_pointer(W.hdata_get('buffer'), buff, 'own_lines') - - if own_lines: - hdata_line = W.hdata_get('line') - hdata_line_data = W.hdata_get('line_data') - line = W.hdata_pointer( - W.hdata_get('lines'), own_lines, 'first_line') - - while line: - data = W.hdata_pointer(hdata_line, line, 'data') - - line_data = {} - - if data: - date = W.hdata_time(hdata_line_data, data, 'date') - print_date = W.hdata_time(hdata_line_data, data, - 'date_printed') - tags = tags_from_line_data(data) - prefix = W.hdata_string(hdata_line_data, data, 'prefix') - message = W.hdata_string(hdata_line_data, data, 'message') - highlight = W.hdata_char(hdata_line_data, data, "highlight") - - line_data = { - 'date': date, - 'date_printed': print_date, - 'tags_array': ','.join(tags), - 'prefix': prefix, - 'message': message, - 'highlight': highlight - } - - lines.append(line_data) - - line = W.hdata_move(hdata_line, line, 1) - - sorted_lines = sorted(lines, key=itemgetter('date')) - lines = [] - - # We need to convert the dates to a string for hdata_update(), this - # will reverse the list at the same time - while sorted_lines: - line = sorted_lines.pop() - new_line = {k: str(v) for k, v in line.items()} - lines.append(new_line) - - MatrixBacklogEvent.update_buffer_lines(lines, own_lines) - - @staticmethod - def update_buffer_lines(new_lines, own_lines): - hdata_line = W.hdata_get('line') - hdata_line_data = W.hdata_get('line_data') - - line = W.hdata_pointer(W.hdata_get('lines'), own_lines, 'first_line') - - while line: - data = W.hdata_pointer(hdata_line, line, 'data') - - if data: - W.hdata_update(hdata_line_data, data, new_lines.pop()) - - line = W.hdata_move(hdata_line, line, 1) - - @classmethod - def from_dict(cls, server, room_id, parsed_dict): - try: - end_token = sanitize_id(parsed_dict["end"]) - - if not parsed_dict["chunk"]: - return cls(server, room_id, end_token, []) - - event_func = partial(MatrixBacklogEvent._room_event_from_dict, - room_id) - - message_events = list( - filter(lambda event: event["type"] == "m.room.message", - parsed_dict["chunk"])) - - events = [event_func(m) for m in message_events] - - return cls(server, room_id, end_token, events) - except (KeyError, ValueError, TypeError): - return MatrixErrorEvent.from_dict(server, "Error fetching backlog", - False, parsed_dict) - - def execute(self): - room = self.server.rooms[self.room_id] - buff = self.server.buffers[self.room_id] - tags = tags_for_message("backlog") - - for event in self.events: - event.execute(self.server, room, buff, list(tags)) - - room.prev_batch = self.end_token - MatrixBacklogEvent.buffer_sort_messages(buff) - room.backlog_pending = False - W.bar_item_update("buffer_modes") - - -class MatrixSyncEvent(MatrixEvent): - - def __init__(self, server, next_batch, room_infos, invited_infos): - self.next_batch = next_batch - self.joined_room_infos = room_infos - self.invited_room_infos = invited_infos - - MatrixEvent.__init__(self, server) - - @staticmethod - def _infos_from_dict(parsed_dict): - join_infos = [] - invite_infos = [] - - for room_id, room_dict in parsed_dict['join'].items(): - if not room_id: - continue - - join_infos.append(RoomInfo.from_dict(room_id, room_dict)) - - return (join_infos, invite_infos) - - @classmethod - def from_dict(cls, server, parsed_dict): - try: - next_batch = sanitize_id(parsed_dict["next_batch"]) - room_info_dict = parsed_dict["rooms"] - - join_infos, invite_infos = MatrixSyncEvent._infos_from_dict( - room_info_dict) - - return cls(server, next_batch, join_infos, invite_infos) - except (KeyError, ValueError, TypeError): - return MatrixErrorEvent.from_dict(server, "Error syncing", False, - parsed_dict) - - def _queue_joined_info(self): - server = self.server - - while self.joined_room_infos: - info = self.joined_room_infos.pop() - - if info.room_id not in server.buffers: - matrix_create_room_buffer(server, info.room_id) - - room = server.rooms[info.room_id] - - if not room.prev_batch: - room.prev_batch = info.prev_batch - - server.event_queue.append(info) - - def execute(self): - server = self.server - - # we got the same batch again, nothing to do - if self.next_batch == server.next_batch: - server.sync() - return - - self._queue_joined_info() - server.next_batch = self.next_batch - - server.handle_events() diff --git a/matrix/globals.py b/matrix/globals.py index 9c125b7..7d34248 100644 --- a/matrix/globals.py +++ b/matrix/globals.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2018 Damir Jelić +# 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 @@ -17,17 +17,32 @@ 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 -from matrix.utf import WeechatWrapper -from matrix.plugin_options import PluginOptions try: import weechat + W = weechat if sys.hexversion >= 0x3000000 else WeechatWrapper(weechat) except ImportError: - import matrix._weechat as weechat + import matrix._weechat as weechat # type: ignore + W = weechat -OPTIONS = PluginOptions() # type: PluginOptions SERVERS = dict() # type: Dict[str, MatrixServer] -CONFIG = None # type: weechat.config +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/matrix/http.py b/matrix/http.py deleted file mode 100644 index 8ad1ef4..0000000 --- a/matrix/http.py +++ /dev/null @@ -1,93 +0,0 @@ -# -*- coding: utf-8 -*- - -# 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 -from builtins import str - -import json -from enum import Enum, unique - - -@unique -class RequestType(Enum): - GET = 0 - POST = 1 - PUT = 2 - - -class HttpResponse: - - def __init__(self, status, headers, body): - self.status = status # type: int - self.headers = headers # type: Dict[str, str] - self.body = body # type: bytes - - -# yapf: disable -class HttpRequest: - def __init__( - self, - request_type, # type: RequestType - host, # type: str - location, # type: str - data=None, # type: Dict[str, Any] - user_agent='weechat-matrix/{version}'.format( - version="0.1") # type: str - ): - # type: (...) -> None - user_agent = 'User-Agent: {agent}'.format(agent=user_agent) - host_header = 'Host: {host}'.format(host=host) - keepalive = "Connection: keep-alive" - request_list = [] # type: List[str] - accept_header = 'Accept: */*' # type: str - end_separator = '\r\n' # type: str - payload = "" # type: str - # yapf: enable - - if request_type == RequestType.GET: - get = 'GET {location} HTTP/1.1'.format(location=location) - request_list = [ - get, host_header, user_agent, keepalive, accept_header, - end_separator - ] - - elif (request_type == RequestType.POST or - request_type == RequestType.PUT): - - json_data = json.dumps(data, separators=(',', ':')) - - if request_type == RequestType.POST: - method = "POST" - else: - method = "PUT" - - request_line = '{method} {location} HTTP/1.1'.format( - method=method, location=location) - - type_header = 'Content-Type: application/x-www-form-urlencoded' - length_header = 'Content-Length: {length}'.format( - length=len(json_data)) - - request_list = [ - request_line, host_header, user_agent, keepalive, - accept_header, length_header, type_header, end_separator - ] - payload = json_data - - request = '\r\n'.join(request_list) - - self.request = request - self.payload = payload diff --git a/matrix/plugin_options.py b/matrix/plugin_options.py deleted file mode 100644 index fc0ecf5..0000000 --- a/matrix/plugin_options.py +++ /dev/null @@ -1,62 +0,0 @@ -# -*- coding: utf-8 -*- - -# 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 -from collections import namedtuple -from enum import Enum, unique - - -@unique -class RedactType(Enum): - STRIKETHROUGH = 0 - NOTICE = 1 - DELETE = 2 - - -@unique -class ServerBufferType(Enum): - MERGE_CORE = 0 - MERGE = 1 - INDEPENDENT = 2 - - -@unique -class DebugType(Enum): - MESSAGING = 0 - NETWORK = 1 - TIMING = 2 - - -Option = namedtuple( - 'Option', - ['name', 'type', 'string_values', 'min', 'max', 'value', 'description']) - - -class PluginOptions: - - def __init__(self): - self.redaction_type = RedactType.STRIKETHROUGH # type: RedactType - self.look_server_buf = ServerBufferType.MERGE_CORE # type: ServerBufferType - - self.sync_limit = 30 # type: int - self.backlog_limit = 10 # type: int - self.enable_backlog = True # type: bool - self.page_up_hook = None # type: weechat.hook - - self.redaction_comp_len = 50 # type: int - - self.options = dict() # type: Dict[str, weechat.config_option] - self.debug = [] # type: List[DebugType] diff --git a/matrix/rooms.py b/matrix/rooms.py deleted file mode 100644 index b0dc06f..0000000 --- a/matrix/rooms.py +++ /dev/null @@ -1,922 +0,0 @@ -# -*- coding: utf-8 -*- - -# 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 -from builtins import str - -from pprint import pformat - -from collections import namedtuple, deque -from datetime import datetime - -from matrix.globals import W, OPTIONS -from matrix.plugin_options import RedactType - -from matrix.colors import Formatted -from matrix.utils import ( - strip_matrix_server, color_for_tags, server_ts_to_weechat, - sender_to_nick_and_color, add_event_tags, sanitize_id, sanitize_ts, - sanitize_string, sanitize_text, shorten_sender, add_user_to_nicklist, - get_prefix_for_level, sanitize_power_level, string_strikethrough, - line_pointer_and_tags_from_event, sender_to_prefix_and_color) - -PowerLevel = namedtuple('PowerLevel', ['user', 'level']) - - -class MatrixRoom: - - def __init__(self, room_id): - # type: (str) -> None - # yapf: disable - self.room_id = room_id # type: str - self.canonical_alias = None # type: str - self.name = None # type: str - self.topic = "" # type: str - self.topic_author = "" # type: str - self.topic_date = None # type: datetime.datetime - self.prev_batch = "" # type: str - self.users = dict() # type: Dict[str, MatrixUser] - self.encrypted = False # type: bool - self.backlog_pending = False # type: bool - # yapf: enable - - def display_name(self, own_user_id): - """ - Calculate display name for a room. - - Prefer returning the room name if it exists, falling back to - a group-style name if not. - - Mostly follows: - https://matrix.org/docs/spec/client_server/r0.3.0.html#id268 - - An exception is that we prepend '#' before the room name to make it - visually distinct from private messages and unnamed groups of users - ("direct chats") in weechat's buffer list. - """ - if self.is_named(): - return self.named_room_name() - else: - return self.group_name(own_user_id) - - def named_room_name(self): - """ - Returns the name of the room, if it's a named room. Otherwise return - None. - """ - if self.name: - return "#" + self.name - elif self.canonical_alias: - return self.canonical_alias - else: - return None - - def group_name(self, own_user_id): - """ - Returns the group-style name of the room, i.e. a name based on the room - members. - """ - # Sort user display names, excluding our own user and using the - # mxid as the sorting key. - # - # TODO: Hook the user display name disambiguation algorithm here. - # Currently, we use the user display names as is, which may not be - # unique. - users = [user.name for mxid, user - in sorted(self.users.items(), key=lambda t: t[0]) - if mxid != own_user_id] - - num_users = len(users) - - if num_users == 1: - return users[0] - elif num_users == 2: - return " and ".join(users) - elif num_users >= 3: - return "{first_user} and {num} others".format( - first_user=users[0], - num=num_users-1) - else: - return "Empty room?" - - - def machine_name(self): - """ - Calculate an unambiguous, unique machine name for a room. - - Either use the more human-friendly canonical alias, if it exists, or - the internal room ID if not. - """ - if self.canonical_alias: - return self.canonical_alias - else: - return self.room_id - - def is_named(self): - """ - Is this a named room? - - A named room is a room with either the name or a canonical alias set. - """ - return self.canonical_alias or self.name - - def is_group(self): - """ - Is this an ad hoc group of users? - - A group is an unnamed room with no canonical alias. - """ - return not self.is_named() - - -class MatrixUser: - - def __init__(self, name, display_name): - # yapf: disable - self.name = name # type: str - self.display_name = display_name # type: str - self.power_level = 0 # type: int - self.nick_color = "" # type: str - self.prefix = "" # type: str - # yapf: enable - - -def matrix_create_room_buffer(server, room_id): - # type: (MatrixServer, str) -> None - buf = W.buffer_new(room_id, "room_input_cb", server.name, "room_close_cb", - server.name) - - W.buffer_set(buf, "localvar_set_type", 'channel') - W.buffer_set(buf, "type", 'formatted') - - W.buffer_set(buf, "localvar_set_channel", room_id) - - W.buffer_set(buf, "localvar_set_nick", server.user) - - W.buffer_set(buf, "localvar_set_server", server.name) - - short_name = strip_matrix_server(room_id) - W.buffer_set(buf, "short_name", short_name) - - W.nicklist_add_group(buf, '', "000|o", "weechat.color.nicklist_group", 1) - W.nicklist_add_group(buf, '', "001|h", "weechat.color.nicklist_group", 1) - W.nicklist_add_group(buf, '', "002|v", "weechat.color.nicklist_group", 1) - W.nicklist_add_group(buf, '', "999|...", "weechat.color.nicklist_group", 1) - - W.buffer_set(buf, "nicklist", "1") - W.buffer_set(buf, "nicklist_display_groups", "0") - - # TODO make this configurable - W.buffer_set(buf, "highlight_tags_restrict", "matrix_message") - - server.buffers[room_id] = buf - server.rooms[room_id] = MatrixRoom(room_id) - - -class RoomInfo(): - - def __init__(self, room_id, prev_batch, events): - # type: (str, str, List[Any], List[Any]) -> None - self.room_id = room_id - self.prev_batch = prev_batch - self.events = deque(events) - - @staticmethod - def _message_from_event(event): - # The transaction id will only be present for events that are send out - # from this client, since we print out our own messages as soon as we - # get a receive confirmation from the server we don't care about our - # own messages in a sync event. More info under: - # https://github.com/matrix-org/matrix-doc/blob/master/api/client-server/definitions/event.yaml#L53 - if "transaction_id" in event["unsigned"]: - return None - - if "redacted_by" in event["unsigned"]: - return RoomRedactedMessageEvent.from_dict(event) - - return RoomMessageEvent.from_dict(event) - - @staticmethod - def _membership_from_dict(event_dict): - if (event_dict["content"]["membership"] not in [ - "invite", "join", "knock", "leave", "ban" - ]): - raise ValueError - - if event_dict["content"]["membership"] == "join": - event = RoomMemberJoin.from_dict(event_dict) - - try: - message = RoomMembershipMessage( - event.event_id, event.sender, event.timestamp, - "has joined", "join") - - return event, message - except AttributeError: - return event, None - - elif event_dict["content"]["membership"] == "leave": - event = RoomMemberLeave.from_dict(event_dict) - - try: - msg = ("has left" if event.sender == event.leaving_user else - "has been kicked") - message = RoomMembershipMessage( - event.event_id, event.leaving_user, event.timestamp, msg, "quit") - return event, message - except AttributeError: - return event, None - - return None, None - - @staticmethod - def parse_event(event_dict): - # type: (Dict[Any, Any]) -> (RoomEvent, RoomEvent) - state_event = None - message_event = None - - if event_dict["type"] == "m.room.message": - message_event = RoomInfo._message_from_event(event_dict) - elif event_dict["type"] == "m.room.member": - state_event, message_event = ( - RoomInfo._membership_from_dict(event_dict)) - elif event_dict["type"] == "m.room.power_levels": - state_event = RoomPowerLevels.from_dict(event_dict) - elif event_dict["type"] == "m.room.topic": - state_event = RoomTopicEvent.from_dict(event_dict) - message_event = RoomTopiceMessage( - state_event.event_id, - state_event.sender, - state_event.timestamp, - state_event.topic) - elif event_dict["type"] == "m.room.redaction": - message_event = RoomRedactionEvent.from_dict(event_dict) - elif event_dict["type"] == "m.room.name": - state_event = RoomNameEvent.from_dict(event_dict) - elif event_dict["type"] == "m.room.canonical_alias": - state_event = RoomAliasEvent.from_dict(event_dict) - elif event_dict["type"] == "m.room.encryption": - state_event = RoomEncryptionEvent.from_dict(event_dict) - - return state_event, message_event - - @staticmethod - def _parse_events(parsed_dict, messages=True, state=True): - state_events = [] - message_events = [] - - if not messages and not state: - return [] - - try: - for event in parsed_dict: - m_event, s_event = RoomInfo.parse_event(event) - state_events.append(m_event) - message_events.append(s_event) - except (ValueError, TypeError, KeyError) as error: - message = ("{prefix}matrix: Error parsing " - "room event of type {type}: {error}").format( - prefix=W.prefix("error"), - type=event["type"], - error=pformat(error)) - W.prnt("", message) - raise - - events = [] - - if state: - events = events + state_events - - if messages: - events = events + message_events - - return events - - @classmethod - def from_dict(cls, room_id, parsed_dict): - prev_batch = sanitize_id(parsed_dict['timeline']['prev_batch']) - - state_dict = parsed_dict['state']['events'] - timeline_dict = parsed_dict['timeline']['events'] - - state_events = RoomInfo._parse_events(state_dict, messages=False) - timeline_events = RoomInfo._parse_events(timeline_dict) - - events = state_events + timeline_events - - return cls(room_id, prev_batch, list(filter(None, events))) - - -class RoomEvent(): - - def __init__(self, event_id, sender, timestamp): - self.event_id = event_id - self.sender = sender - self.timestamp = timestamp - - -class RoomRedactedMessageEvent(RoomEvent): - - def __init__(self, event_id, sender, timestamp, censor, reason=None): - self.censor = censor - self.reason = reason - RoomEvent.__init__(self, event_id, sender, timestamp) - - @classmethod - def from_dict(cls, event): - event_id = sanitize_id(event["event_id"]) - sender = sanitize_id(event["sender"]) - timestamp = sanitize_ts(event["origin_server_ts"]) - - censor = sanitize_id(event['unsigned']['redacted_because']['sender']) - reason = None - - if 'reason' in event['unsigned']['redacted_because']['content']: - reason = sanitize_text( - event['unsigned']['redacted_because']['content']['reason']) - - return cls(event_id, sender, timestamp, censor, reason) - - def execute(self, server, room, buff, tags): - nick, color_name = sender_to_nick_and_color(room, self.sender) - color = color_for_tags(color_name) - date = server_ts_to_weechat(self.timestamp) - - event_tags = add_event_tags(self.event_id, nick, color, tags) - - reason = (", reason: \"{reason}\"".format(reason=self.reason) - if self.reason else "") - - censor, _ = sender_to_nick_and_color(room, self.censor) - - 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) - - event_tags.append("matrix_redacted") - - tags_string = ",".join(event_tags) - - data = "{author}\t{msg}".format(author=nick, msg=msg) - - W.prnt_date_tags(buff, date, tags_string, data) - - -class RoomMessageEvent(RoomEvent): - - @classmethod - def from_dict(cls, event): - if event['content']['msgtype'] == 'm.text': - return RoomMessageText.from_dict(event) - elif event['content']['msgtype'] == 'm.image': - return RoomMessageMedia.from_dict(event) - elif event['content']['msgtype'] == 'm.audio': - return RoomMessageMedia.from_dict(event) - elif event['content']['msgtype'] == 'm.file': - return RoomMessageMedia.from_dict(event) - elif event['content']['msgtype'] == 'm.video': - return RoomMessageMedia.from_dict(event) - elif event['content']['msgtype'] == 'm.emote': - return RoomMessageEmote.from_dict(event) - elif event['content']['msgtype'] == 'm.notice': - return RoomMessageNotice.from_dict(event) - return RoomMessageUnknown.from_dict(event) - - def _print_message(self, message, room, buff, tags): - nick, color_name = sender_to_nick_and_color(room, self.sender) - color = color_for_tags(color_name) - - event_tags = add_event_tags(self.event_id, nick, color, tags) - - tags_string = ",".join(event_tags) - - prefix, prefix_color = sender_to_prefix_and_color(room, self.sender) - - prefix_string = ("" if not prefix else "{}{}{}".format( - W.color(prefix_color), prefix, W.color("reset"))) - - data = "{prefix}{color}{author}{ncolor}\t{msg}".format( - prefix=prefix_string, - color=W.color(color_name), - author=nick, - ncolor=W.color("reset"), - msg=message) - - date = server_ts_to_weechat(self.timestamp) - W.prnt_date_tags(buff, date, tags_string, data) - - -class RoomMessageSimple(RoomMessageEvent): - - def __init__(self, event_id, sender, timestamp, message, message_type): - self.message = message - self.message_type = message_type - RoomEvent.__init__(self, event_id, sender, timestamp) - - @classmethod - def from_dict(cls, event): - event_id = sanitize_id(event["event_id"]) - sender = sanitize_id(event["sender"]) - timestamp = sanitize_ts(event["origin_server_ts"]) - - message = sanitize_text(event["content"]["body"]) - message_type = sanitize_text(event["content"]["msgtype"]) - - return cls(event_id, sender, timestamp, message, message_type) - - -class RoomMessageUnknown(RoomMessageSimple): - - def execute(self, server, room, buff, tags): - msg = ("Unknown message of type {t}, body: {body}").format( - t=self.message_type, body=self.message) - - self._print_message(msg, room, buff, tags) - - -class RoomMessageText(RoomMessageEvent): - - def __init__(self, event_id, sender, timestamp, message, formatted_message=None): - self.message = message - self.formatted_message = formatted_message - RoomEvent.__init__(self, event_id, sender, timestamp) - - @classmethod - def from_dict(cls, event): - event_id = sanitize_id(event["event_id"]) - sender = sanitize_id(event["sender"]) - timestamp = sanitize_ts(event["origin_server_ts"]) - - msg = "" - formatted_msg = None - - msg = sanitize_text(event['content']['body']) - - if ('format' in event['content'] and - 'formatted_body' in event['content']): - if event['content']['format'] == "org.matrix.custom.html": - formatted_msg = Formatted.from_html( - sanitize_text(event['content']['formatted_body'])) - - return cls(event_id, sender, timestamp, msg, formatted_msg) - - def execute(self, server, room, buff, tags): - msg = (self.formatted_message.to_weechat() - if self.formatted_message else self.message) - - self._print_message(msg, room, buff, tags) - - -class RoomMessageEmote(RoomMessageSimple): - - def execute(self, server, room, buff, tags): - nick, color_name = sender_to_nick_and_color(room, self.sender) - color = color_for_tags(color_name) - - event_tags = add_event_tags(self.event_id, nick, color, tags) - event_tags.append("matrix_action") - - tags_string = ",".join(event_tags) - - data = "{prefix}{nick_color}{author}{ncolor} {msg}".format( - prefix=W.prefix("action"), - nick_color=W.color(color_name), - author=nick, - ncolor=W.color("reset"), - msg=self.message) - - date = server_ts_to_weechat(self.timestamp) - W.prnt_date_tags(buff, date, tags_string, data) - - -class RoomMessageNotice(RoomMessageText): - - def execute(self, server, room, buff, tags): - msg = "{color}{message}{ncolor}".format( - color=W.color("irc.color.notice"), - message=self.message, - ncolor=W.color("reset")) - - self._print_message(msg, room, buff, tags) - - -class RoomMessageMedia(RoomMessageEvent): - - def __init__(self, event_id, sender, timestamp, url, description): - self.url = url - self.description = description - RoomEvent.__init__(self, event_id, sender, timestamp) - - @classmethod - def from_dict(cls, event): - event_id = sanitize_id(event["event_id"]) - sender = sanitize_id(event["sender"]) - timestamp = sanitize_ts(event["origin_server_ts"]) - - mxc_url = sanitize_text(event['content']['url']) - description = sanitize_text(event["content"]["body"]) - - return cls(event_id, sender, timestamp, mxc_url, description) - - def execute(self, server, room, buff, tags): - http_url = server.client.mxc_to_http(self.url) - url = http_url if http_url else self.url - - description = (" ({})".format(self.description) - if self.description else "") - - msg = "{url}{desc}".format(url=url, desc=description) - - self._print_message(msg, room, buff, tags) - - -class RoomMembershipMessage(RoomEvent): - def __init__(self, event_id, sender, timestamp, message, prefix): - self.message = message - self.prefix = prefix - RoomEvent.__init__(self, event_id, sender, timestamp) - - def execute(self, server, room, buff, tags): - nick, color_name = sender_to_nick_and_color(room, self.sender) - event_tags = add_event_tags(self.event_id, nick, None, []) - # TODO this should be configurable - action_color = "red" if self.prefix == "quit" else "green" - - data = ("{prefix}{color}{author}{ncolor} " - "{del_color}({host_color}{user_id}{del_color})" - "{action_color} {message} " - "{channel_color}{room}{ncolor}").format( - prefix=W.prefix(self.prefix), - color=W.color(color_name), - author=nick, - ncolor=W.color("reset"), - del_color=W.color("chat_delimiters"), - host_color=W.color("chat_host"), - user_id=self.sender, - action_color=W.color(action_color), - message=self.message, - channel_color=W.color("chat_channel"), - room="" if room.is_group() else room.named_room_name()) - date = server_ts_to_weechat(self.timestamp) - tags_string = ",".join(event_tags) - - W.prnt_date_tags(buff, date, tags_string, data) - - -class RoomMemberJoin(RoomEvent): - - def __init__(self, event_id, sender, timestamp, display_name): - self.display_name = display_name - RoomEvent.__init__(self, event_id, sender, timestamp) - - @classmethod - def from_dict(cls, event_dict): - event_id = sanitize_id(event_dict["event_id"]) - sender = sanitize_id(event_dict["sender"]) - timestamp = sanitize_ts(event_dict["origin_server_ts"]) - display_name = None - - if event_dict["content"]: - if "display_name" in event_dict["content"]: - display_name = sanitize_text( - event_dict["content"]["displayname"]) - - return cls(event_id, sender, timestamp, display_name) - - def execute(self, server, room, buff, tags): - short_name = shorten_sender(self.sender) - - if self.sender in room.users: - user = room.users[self.sender] - if self.display_name: - user.display_name = self.display_name - else: - user = MatrixUser(short_name, self.display_name) - - if not user.nick_color: - if self.sender == server.user_id: - highlight_words = [self.sender, user.name] - - if self.display_name: - highlight_words.append(self.display_name) - - user.nick_color = "weechat.color.chat_nick_self" - W.buffer_set(buff, "highlight_words", ",".join(highlight_words)) - else: - user.nick_color = W.info_get("nick_color_name", user.name) - - room.users[self.sender] = user - - nick_pointer = W.nicklist_search_nick(buff, "", self.sender) - - if not nick_pointer: - add_user_to_nicklist(buff, self.sender, user) - - # calculate room display name and set it as the buffer list name - room_name = room.display_name(server.user_id) - W.buffer_set(buff, "short_name", room_name) - - -class RoomMemberLeave(RoomEvent): - - def __init__(self, event_id, sender, leaving_user, timestamp): - self.leaving_user = leaving_user - RoomEvent.__init__(self, event_id, sender, timestamp) - - @classmethod - def from_dict(cls, event_dict): - event_id = sanitize_id(event_dict["event_id"]) - sender = sanitize_id(event_dict["sender"]) - leaving_user = sanitize_id(event_dict["state_key"]) - timestamp = sanitize_ts(event_dict["origin_server_ts"]) - - return cls(event_id, sender, leaving_user, timestamp) - - def execute(self, server, room, buff, tags): - if self.leaving_user in room.users: - nick_pointer = W.nicklist_search_nick(buff, "", self.leaving_user) - - if nick_pointer: - W.nicklist_remove_nick(buff, nick_pointer) - - del room.users[self.leaving_user] - - # calculate room display name and set it as the buffer list name - room_name = room.display_name(server.user_id) - W.buffer_set(buff, "short_name", room_name) - - -class RoomPowerLevels(RoomEvent): - - def __init__(self, event_id, sender, timestamp, power_levels): - self.power_levels = power_levels - RoomEvent.__init__(self, event_id, sender, timestamp) - - @classmethod - def from_dict(cls, event_dict): - event_id = sanitize_id(event_dict["event_id"]) - sender = sanitize_id(event_dict["sender"]) - timestamp = sanitize_ts(event_dict["origin_server_ts"]) - power_levels = [] - - for user, level in event_dict["content"]["users"].items(): - power_levels.append( - PowerLevel(sanitize_id(user), sanitize_power_level(level))) - - return cls(event_id, sender, timestamp, power_levels) - - def _set_power_level(self, room, buff, power_level): - user_id = power_level.user - level = power_level.level - - if user_id not in room.users: - return - - user = room.users[user_id] - user.power_level = level - user.prefix = get_prefix_for_level(level) - - nick_pointer = W.nicklist_search_nick(buff, "", user_id) - - if nick_pointer: - W.nicklist_remove_nick(buff, nick_pointer) - add_user_to_nicklist(buff, user_id, user) - - def execute(self, server, room, buff, tags): - for level in self.power_levels: - self._set_power_level(room, buff, level) - - -class RoomTopiceMessage(RoomEvent): - - def __init__(self, event_id, sender, timestamp, topic): - self.topic = topic - RoomEvent.__init__(self, event_id, sender, timestamp) - - def execute(self, server, room, buff, tags): - topic = self.topic - - nick, color_name = sender_to_nick_and_color(room, self.sender) - - author = ("{nick_color}{user}{ncolor}").format( - nick_color=W.color(color_name), user=nick, ncolor=W.color("reset")) - - # TODO print old topic if configured so - if room.is_named(): - message = ("{prefix}{nick} has changed " - "the topic for {chan_color}{room}{ncolor} " - "to \"{topic}\"").format( - prefix=W.prefix("network"), - nick=author, - chan_color=W.color("chat_channel"), - ncolor=W.color("reset"), - room=room.named_room_name(), - topic=topic) - else: - message = ('{prefix}{nick} has changed the topic to ' - '"{topic}"').format( - prefix=W.prefix("network"), - nick=author, - topic=topic) - - tags = ["matrix_topic", "log3", "matrix_id_{}".format(self.event_id)] - date = server_ts_to_weechat(self.timestamp) - W.prnt_date_tags(buff, date, ",".join(tags), message) - - -class RoomTopicEvent(RoomEvent): - - def __init__(self, event_id, sender, timestamp, topic): - self.topic = topic - RoomEvent.__init__(self, event_id, sender, timestamp) - - @classmethod - def from_dict(cls, event_dict): - event_id = sanitize_id(event_dict["event_id"]) - sender = sanitize_id(event_dict["sender"]) - timestamp = sanitize_ts(event_dict["origin_server_ts"]) - - topic = sanitize_text(event_dict["content"]["topic"]) - - return cls(event_id, sender, timestamp, topic) - - def execute(self, server, room, buff, tags): - topic = self.topic - - W.buffer_set(buff, "title", topic) - - room.topic = topic - room.topic_author = self.sender - room.topic_date = datetime.fromtimestamp( - server_ts_to_weechat(self.timestamp)) - - -class RoomRedactionEvent(RoomEvent): - - def __init__(self, event_id, sender, timestamp, redaction_id, reason=None): - self.redaction_id = redaction_id - self.reason = reason - RoomEvent.__init__(self, event_id, sender, timestamp) - - @classmethod - def from_dict(cls, event_dict): - event_id = sanitize_id(event_dict["event_id"]) - sender = sanitize_id(event_dict["sender"]) - timestamp = sanitize_ts(event_dict["origin_server_ts"]) - - redaction_id = sanitize_id(event_dict["redacts"]) - - reason = (sanitize_text(event_dict["content"]["reason"]) - if "reason" in event_dict["content"] else None) - - return cls(event_id, sender, timestamp, redaction_id, reason) - - @staticmethod - def already_redacted(tags): - if "matrix_redacted" in tags: - return True - return False - - def _redact_line(self, data_pointer, tags, room, buff): - hdata_line_data = W.hdata_get('line_data') - - message = W.hdata_string(hdata_line_data, data_pointer, 'message') - censor, _ = sender_to_nick_and_color(room, self.sender) - - reason = ("" if not self.reason else - ", reason: \"{reason}\"".format(reason=self.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) - - new_message = "" - - if OPTIONS.redaction_type == RedactType.STRIKETHROUGH: - plaintext_msg = W.string_remove_color(message, '') - new_message = string_strikethrough(plaintext_msg) - elif OPTIONS.redaction_type == RedactType.NOTICE: - new_message = message - elif OPTIONS.redaction_type == RedactType.DELETE: - pass - - message = " ".join(s for s in [new_message, redaction_msg] if s) - - tags.append("matrix_redacted") - - new_data = {'tags_array': ','.join(tags), 'message': message} - - W.hdata_update(hdata_line_data, data_pointer, new_data) - - def execute(self, server, room, buff, tags): - data_pointer, tags = line_pointer_and_tags_from_event( - buff, self.redaction_id) - - if not data_pointer: - return - - if RoomRedactionEvent.already_redacted(tags): - return - - self._redact_line(data_pointer, tags, room, buff) - - -class RoomNameEvent(RoomEvent): - - def __init__(self, event_id, sender, timestamp, name): - self.name = name - RoomEvent.__init__(self, event_id, sender, timestamp) - - @classmethod - def from_dict(cls, event_dict): - event_id = sanitize_id(event_dict["event_id"]) - sender = sanitize_id(event_dict["sender"]) - timestamp = sanitize_ts(event_dict["origin_server_ts"]) - - name = sanitize_string(event_dict['content']['name']) - - return cls(event_id, sender, timestamp, name) - - def execute(self, server, room, buff, tags): - if not self.name: - return - - room.name = self.name - W.buffer_set(buff, "name", self.name) - W.buffer_set(buff, "localvar_set_channel", self.name) - - # calculate room display name and set it as the buffer list name - room_name = room.display_name(server.user_id) - W.buffer_set(buff, "short_name", room_name) - - -class RoomAliasEvent(RoomEvent): - - def __init__(self, event_id, sender, timestamp, canonical_alias): - self.canonical_alias = canonical_alias - RoomEvent.__init__(self, event_id, sender, timestamp) - - @classmethod - def from_dict(cls, event_dict): - event_id = sanitize_id(event_dict["event_id"]) - sender = sanitize_id(event_dict["sender"]) - timestamp = sanitize_ts(event_dict["origin_server_ts"]) - - canonical_alias = sanitize_id(event_dict["content"]["alias"]) - - return cls(event_id, sender, timestamp, canonical_alias) - - def execute(self, server, room, buff, tags): - if not self.canonical_alias: - return - - # TODO: What should we do with this? - # W.buffer_set(buff, "name", self.name) - # W.buffer_set(buff, "localvar_set_channel", self.name) - - # calculate room display name and set it as the buffer list name - room.canonical_alias = self.canonical_alias - room_name = room.display_name(server.user_id) - W.buffer_set(buff, "short_name", room_name) - - -class RoomEncryptionEvent(RoomEvent): - - @classmethod - def from_dict(cls, event_dict): - event_id = sanitize_id(event_dict["event_id"]) - sender = sanitize_id(event_dict["sender"]) - timestamp = sanitize_ts(event_dict["origin_server_ts"]) - - return cls(event_id, sender, timestamp) - - def execute(self, server, room, buff, tags): - room.encrypted = True - - message = ("{prefix}This room is encrypted, encryption is " - "currently unsuported. Message sending is disabled for " - "this room.").format(prefix=W.prefix("error")) - - W.prnt(buff, message) diff --git a/matrix/server.py b/matrix/server.py index ab67015..404c571 100644 --- a/matrix/server.py +++ b/matrix/server.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright © 2018 Damir Jelić +# 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 @@ -15,177 +15,314 @@ # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. from __future__ import unicode_literals -from builtins import str, bytes import os -import ssl -import socket -import time -import datetime 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 -from collections import deque -from http_parser.pyparser import HttpParser +from uuid import UUID -from matrix.plugin_options import Option, DebugType -from matrix.utils import (key_from_value, prnt_debug, server_buffer_prnt, - create_server_buffer, tags_for_message) -from matrix.utf import utf8_decode -from matrix.globals import W, SERVERS, OPTIONS -import matrix.api as API -from matrix.api import MatrixClient, MatrixSyncMessage, MatrixLoginMessage +from nio import ( + Api, + HttpClient, + LocalProtocolError, + LoginResponse, + Response, + Rooms, + RoomSendResponse, + SyncResponse, + PartialSyncResponse, + ShareGroupSessionResponse, + KeysQueryResponse, + KeysClaimResponse, + DevicesResponse, + UpdateDeviceResponse, + DeleteDevicesAuthResponse, + DeleteDevicesResponse, + TransportType, + RoomMessagesResponse, + EncryptionError, + GroupEncryptionError, + OlmTrustError, + ErrorResponse, + SyncError, + LoginError, + JoinedMembersResponse, + JoinedMembersError, + RoomKeyEvent +) + +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 .colors import Formatted, FormattedString, DEFAULT_ATTRIBUTES -class MatrixServer: +try: + FileNotFoundError # type: ignore +except NameError: + FileNotFoundError = IOError + + +EncrytpionQueueItem = NamedTuple( + "EncrytpionQueueItem", + [ + ("message_type", str), + ("formatted_message", Formatted), + ], +) + + +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_file): - # type: (str, weechat.config) -> None + def __init__(self, name, config_ptr): + # type: (str, str) -> None # yapf: disable self.name = name # type: str self.user_id = "" - self.address = "" # type: str - self.port = 8448 # type: int - self.options = dict() # type: Dict[str, weechat.config] - self.device_name = "Weechat Matrix" # type: str self.device_id = "" # type: str - self.user = "" # type: str - self.password = "" # 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.rooms = dict() # type: Dict[str, MatrixRoom] - self.buffers = dict() # type: Dict[str, weechat.buffer] - self.server_buffer = None # type: weechat.buffer - self.fd_hook = None # type: weechat.hook - self.ssl_hook = None # type: weechat.hook - self.timer_hook = None # type: weechat.hook - self.numeric_address = "" # type: str - - self.autoconnect = False # type: bool - self.connected = False # type: bool - self.connecting = False # type: bool - self.proxy = None # type: str - self.reconnect_delay = 0 # type: int - self.reconnect_time = None # type: float - self.socket = None # type: ssl.SSLSocket + self.connected = False # type: bool + self.connecting = False # type: bool + self.keys_queried = 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.client = None - self.access_token = None # type: str - self.next_batch = None # type: str + 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: weechat.hook + self.send_fd_hook = None # type: Optional[str] self.send_buffer = b"" # type: bytes - self.current_message = None # type: MatrixMessage + self.device_check_timestamp = None # type: Optional[int] - self.http_parser = HttpParser() # type: HttpParser - self.http_buffer = [] # type: List[bytes] + self.device_deletion_queue = dict() # type: Dict[str, str] - # Queue of messages we need to send off. - self.send_queue = deque() # type: Deque[MatrixMessage] + self.encryption_queue = defaultdict(deque) \ + # type: DefaultDict[str, Deque[EncrytpionQueueItem]] + self.backlog_queue = dict() # type: Dict[str, str] - # Queue of messages we send off and are waiting a response for - self.receive_queue = deque() # type: Deque[MatrixMessage] + 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] - self.event_queue_timer = None - self.event_queue = deque() # type: Deque[RoomInfo] + self.keys_claimed = defaultdict(bool) # type: Dict[str, bool] + self.group_session_shared = defaultdict(bool) # type: Dict[str, bool] - self._create_options(config_file) + self.config = ServerConfig(self.name, config_ptr) self._create_session_dir() - self._load_devide_id() # 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")) + message = ( + "{prefix}matrix: Error creating server session " "directory" + ).format(prefix=W.prefix("error")) W.prnt("", message) - def _get_session_path(self): - home_dir = W.info_get('weechat_dir', '') + def get_session_path(self): + home_dir = W.info_get("weechat_dir", "") return os.path.join(home_dir, "matrix", self.name) - def _load_devide_id(self): - file_name = "{}{}".format(self.name, ".device_id") - path = os.path.join(self._get_session_path(), file_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 f: - device_id = f.readline().rstrip() + 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.name, ".device_id") - path = os.path.join(self._get_session_path(), file_name) + file_name = "{}{}".format(self.config.username, ".device_id") + path = os.path.join(self.get_session_path(), file_name) - with open(path, 'w') as f: - f.write(self.device_id) - - def _create_options(self, config_file): - 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, '8448', - "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"), - ] - - section = W.config_search_section(config_file, 'server') - - for option in options: - option_name = "{server}.{option}".format( - server=self.name, option=option.name) - - self.options[option.name] = W.config_new_option( - config_file, 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.name, "", "") - - def reset_parser(self): - self.http_parser = HttpParser() - self.http_buffer = [] + with atomic_write(path, overwrite=True) as device_file: + device_file.write(self.device_id) def _change_client(self): - host = ':'.join([self.address, str(self.port)]) - user_agent = 'weechat-matrix/{version}'.format(version="0.1") - self.client = MatrixClient(host, user_agent=user_agent) + host = ":".join([self.config.address, str(self.config.port)]) + self.client = HttpClient( + host, + self.config.username, + self.device_id, + self.get_session_path() + ) def update_option(self, option, option_name): if option_name == "address": - value = W.config_string(option) - self.address = value self._change_client() - elif option_name == "autoconnect": - value = W.config_boolean(option) - self.autoconnect = value elif option_name == "port": - value = W.config_integer(option) - self.port = value self._change_client() - elif option_name == "proxy": - value = W.config_string(option) - self.proxy = value elif option_name == "ssl_verify": value = W.config_boolean(option) if value: @@ -196,31 +333,29 @@ class MatrixServer: self.ssl_context.verify_mode = ssl.CERT_NONE elif option_name == "username": value = W.config_string(option) - self.user = value self.access_token = "" - elif option_name == "password": - value = W.config_string(option) - self.password = W.string_eval_expression(value, {}, {}, {}) - elif option_name == "device_name": - value = W.config_string(option) - self.device_name = value + + 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, message): - # type: (MatrixServer, MatrixMessage) -> None - if not self.send(message): - prnt_debug(DebugType.MESSAGING, self, - ("{prefix} Failed sending message of type {t}. " - "Adding to queue").format( - prefix=W.prefix("error"), - t=message.__class__.__name__)) - self.send_queue.append(message) + 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) @@ -241,14 +376,17 @@ class MatrixServer: 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) + 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, + ("{prefix}matrix: disconnecting from server...").format( + prefix=W.prefix("network") + ), + ) self.disconnect() return False @@ -259,10 +397,15 @@ class MatrixServer: server_buffer_prnt( self, "{prefix}matrix: Error while writing to socket".format( - prefix=W.prefix("network"))) + prefix=W.prefix("network") + ), + ) server_buffer_prnt( - self, ("{prefix}matrix: disconnecting from server..." - ).format(prefix=W.prefix("network"))) + self, + ("{prefix}matrix: disconnecting from server...").format( + prefix=W.prefix("network") + ), + ) self.disconnect() return False @@ -272,37 +415,38 @@ class MatrixServer: return True def _abort_send(self): - self.send_queue.appendleft(self.current_message) - self.current_message = None - self.send_buffer = "" + self.send_buffer = b"" def _finalize_send(self): # type: (MatrixServer) -> None - self.current_message.send_time = time.time() - self.receive_queue.append(self.current_message) - self.send_buffer = b"" - self.current_message = None - def send(self, message): - # type: (MatrixServer, MatrixMessage) -> bool - if self.current_message: - return False + def info(self, message): + buf = "" + if self.server_buffer: + buf = self.server_buffer - self.current_message = message + msg = "{}{}: {}".format(W.prefix("network"), SCRIPT_NAME, message) + W.prnt(buf, msg) - request = message.request.request - payload = message.request.payload + def error(self, message): + buf = "" + if self.server_buffer: + buf = self.server_buffer - bytes_message = bytes(request, 'utf-8') + bytes(payload, 'utf-8') + msg = "{}{}: {}".format(W.prefix("error"), SCRIPT_NAME, message) + W.prnt(buf, msg) - self.try_send(bytes_message) + 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")) + message = ("{prefix}matrix: reconnecting to server...").format( + prefix=W.prefix("network") + ) server_buffer_prnt(self, message) @@ -319,11 +463,11 @@ class MatrixServer: if self.reconnect_delay: self.reconnect_delay = self.reconnect_delay * 2 else: - self.reconnect_delay = 10 + 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) + message = ( + "{prefix}matrix: reconnecting to server in {t} " "seconds" + ).format(prefix=W.prefix("network"), t=self.reconnect_delay) server_buffer_prnt(self, message) @@ -332,10 +476,14 @@ class MatrixServer: if self.socket: try: self.socket.shutdown(socket.SHUT_RDWR) - self.socket.close() except socket.error: pass + try: + self.socket.close() + except OSError: + pass + def disconnect(self, reconnect=True): # type: (bool) -> None if self.fd_hook: @@ -347,37 +495,46 @@ class MatrixServer: self.socket = None self.connected = False self.access_token = "" - self.send_queue.clear() - self.receive_queue.clear() self.send_buffer = b"" - self.current_message = None - self.reset_parser() + 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_delay = 0 self.reconnect_time = None if self.server_buffer: - message = ("{prefix}matrix: disconnected from server" - ).format(prefix=W.prefix("network")) + 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.address or not self.port: + if not self.config.address or not self.config.port: + W.prnt("", self.config.address) message = "{prefix}Server address or port not set".format( - prefix=W.prefix("error")) + prefix=W.prefix("error") + ) W.prnt("", message) return False - if not self.user or not self.password: + if not self.config.username or not self.config.password: message = "{prefix}User or password not set".format( - prefix=W.prefix("error")) + prefix=W.prefix("error") + ) W.prnt("", message) return False @@ -388,155 +545,921 @@ class MatrixServer: create_server_buffer(self) if not self.timer_hook: - self.timer_hook = W.hook_timer(1 * 1000, 0, 0, "matrix_timer_cb", - self.name) + 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.port, - ssl=ssl_message) + message = ( + "{prefix}matrix: Connecting to " "{server}:{port}{ssl}..." + ).format( + prefix=W.prefix("network"), + server=self.config.address, + port=self.config.port, + ssl=ssl_message, + ) W.prnt(self.server_buffer, message) - W.hook_connect(self.proxy if self.proxy else "", - self.address, self.port, - 1, 0, "", "connect_cb", - self.name) + W.hook_connect( + self.config.proxy, + self.config.address, + self.config.port, + 1, + 0, + "", + "connect_cb", + self.name, + ) return True - def sync(self): - limit = None if self.next_batch else OPTIONS.sync_limit - message = MatrixSyncMessage(self.client, self.next_batch, limit) - self.send_queue.append(message) + 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: (MatrixServer) -> None - message = MatrixLoginMessage(self.client, self.user, self.password, - self.device_name, self.device_id) - self.send_or_queue(message) + # type: () -> None + if not self.client: + return - msg = "{prefix}matrix: Logging in...".format(prefix=W.prefix("network")) + 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): + if room_buffer.room.encrypted: + return + + _, 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 False + + assert self.client + + if room_buffer.room.encrypted: + room_buffer.error("Uploading to encrypted rooms is " + "not yet implemented") + return False + + # TODO the content is different if the room is encrypted. + content = { + "msgtype": Api.mimetype_to_msgtype(upload.mimetype), + "body": upload.file_name, + "url": upload.content_uri, + } + + try: + uuid = self.room_send_event(upload.room_id, content) + except (EncryptionError, GroupEncryptionError): + # TODO put the message in a queue to resend after group sessions + # are shared + # message = EncrytpionQueueItem(msgtype, formatted) + # self.encryption_queue[room.room_id].append(message) + return False + + http_url = Api.mxc_to_http(upload.content_uri) + description = ("/{}".format(upload.file_name) if upload.file_name + else "") + + attributes = DEFAULT_ATTRIBUTES.copy() + formatted = Formatted([FormattedString( + "{url}{desc}".format(url=http_url, desc=description), + 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 room_send_event( + self, + room_id, # type: str + content, # type: Dict[str, str] + event_type="m.room.message" + ): + # 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]: + _, request = self.client.share_group_session(room_id) + self.group_session_shared[room_id] = True + self.send(request) + 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 + ): + # 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) + 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, + ( + "{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 _loop_events(self, info, n): + def handle_own_messages_error(self, response): + room_buffer = self.room_buffers[response.room_id] - for i in range(n+1): - try: - event = info.events.popleft() - except IndexError: - return i + if response.uuid not in room_buffer.printed_before_ack_queue: + return - room = self.rooms[info.room_id] - buf = self.buffers[info.room_id] + 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) - tags = tags_for_message("message") - event.execute(self, room, buf, tags) - - self.event_queue.appendleft(info) - return i - - def handle_events(self): - n = 25 - - while True: - try: - info = self.event_queue.popleft() - except IndexError: - if self.event_queue_timer: - W.unhook(self.event_queue_timer) - self.event_queue_timer = None - - self.sync() + def handle_own_messages(self, response): + def send_marker(): + if not room_buffer.read_markers_enabled: return - ret = self._loop_events(info, n) + self.room_send_read_marker(response.room_id, response.event_id) + room_buffer.last_read_event = response.event_id - if ret < n: - n = n - ret - else: - self.event_queue.appendleft(info) + room_buffer = self.room_buffers[response.room_id] - if not self.event_queue_timer: - hook = W.hook_timer(1 * 100, 0, 0, "matrix_event_timer_cb", - self.name) - self.event_queue_timer = hook + message = room_buffer.sent_messages_queue.pop(response.uuid, None) - return + # The message might have been returned in a sync response before we got + # a room send response. + if not message: + return - def handle_response(self, message): - # type: (MatrixMessage) -> None + 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 - assert message.response + if isinstance(message, OwnAction): + room_buffer.self_action(message) + send_marker() + return + if isinstance(message, OwnMessage): + room_buffer.self_message(message) + send_marker() + return - if ('content-type' in message.response.headers and - message.response.headers['content-type'] == 'application/json'): - ret, error = message.decode_body(self) + raise NotImplementedError( + "Unsupported message of type {}".format(type(message)) + ) - if not ret: - message = ("{prefix}matrix: Error decoding json response from " - "server: {error}").format( - prefix=W.prefix("error"), error=error) - W.prnt(self.server_buffer, message) - return + def handle_backlog_response(self, response): + room_id = self.backlog_queue.pop(response.uuid) + room_buffer = self.find_room_from_id(room_id) - event = message.event - event.execute() - else: - status_code = message.response.status - if status_code == 504: - if isinstance(message, API.MatrixSyncMessage): - self.sync() - else: - self._print_message_error(message) - else: - self._print_message_error(message) + room_buffer.handle_backlog(response) - creation_date = datetime.datetime.fromtimestamp(message.creation_time) - done_time = time.time() - info_message = ( - "Message of type {t} created at {c}." - "\nMessage lifetime information:" - "\n Send delay: {s} ms" - "\n Receive delay: {r} ms" - "\n Handling time: {h} ms" - "\n Total time: {total} ms").format( - t=message.__class__.__name__, - c=creation_date, - s=(message.send_time - message.creation_time) * 1000, - r=(message.receive_time - message.send_time) * 1000, - h=(done_time - message.receive_time) * 1000, - total=(done_time - message.creation_time) * 1000, + 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 ) - prnt_debug(DebugType.TIMING, self, info_message) + 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)) - return + 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( + "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("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 _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 not isinstance(event, RoomKeyEvent): + continue + + 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, RoomSendResponse): + self.handle_own_messages_error(response) + + def handle_response(self, response): + # type: (Response) -> None + self.lag = response.elapsed * 1000 + self.lag_done = True + W.bar_item_update("lag") + + if isinstance(response, ErrorResponse): + self.handle_error_response(response) + + 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: + _, request = self.client.share_group_session( + response.room_id, + ignore_missing_sessions=True + ) + 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) + self.encryption_queue[response.room_id].clear() + return + + self.send(request) + + elif isinstance(response, ShareGroupSessionResponse): + room_id = response.room_id + self.group_session_shared[response.room_id] = False + room_buffer = self.room_buffers[room_id] + + while self.encryption_queue[room_id]: + message = self.encryption_queue[room_id].popleft() + try: + if not self.room_send_message(room_buffer, + message.formatted_message, + message.message_type): + self.encryption_queue.pop() + self.encryption_queue[room_id].appendleft(message) + break + except OlmTrustError: + self.encryption_queue[room_id].clear() + break + + def create_room_buffer(self, room_id, prev_batch): + room = self.client.rooms[room_id] + buf = RoomBuffer(room, self.name, prev_batch) + # TODO this should turned into a propper class + 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): +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_name, option = option_name.rsplit(".", 1) server = None if server_name in SERVERS: @@ -546,8 +1469,10 @@ def matrix_config_server_read_cb(data, config_file, section, option_name, SERVERS[server.name] = server # Ignore invalid options - if option in server.options: - return_code = W.config_option_set(server.options[option], value, 1) + 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 @@ -560,7 +1485,7 @@ def matrix_config_server_write_cb(data, config_file, section_name): return W.WECHAT_CONFIG_WRITE_ERROR for server in SERVERS.values(): - for option in server.options.values(): + for option in server.config._option_ptrs.values(): if not W.config_write_option(config_file, option): return W.WECHAT_CONFIG_WRITE_ERROR @@ -569,27 +1494,74 @@ def matrix_config_server_write_cb(data, config_file, section_name): @utf8_decode def matrix_config_server_change_cb(server_name, option): - # type: (str, weechat.config_option) -> int + # 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.options, option) + 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)): + if ( + (not server.connected) + and server.reconnect_time + and current_time >= (server.reconnect_time + server.reconnect_delay) + ): server.reconnect() return W.WEECHAT_RC_OK @@ -597,39 +1569,36 @@ def matrix_timer_cb(server_name, remaining_calls): return W.WEECHAT_RC_OK # check lag, disconnect if it's too big - if server.receive_queue: - message = server.receive_queue.popleft() - server.lag = (current_time - message.send_time) * 1000 - server.receive_queue.appendleft(message) - server.lag_done = False - W.bar_item_update("lag") + server.lag = server.client.lag * 1000 + server.lag_done = False + W.bar_item_update("lag") - # TODO print out message, make timeout configurable - if server.lag > 300000: - server.disconnect() - return W.WEECHAT_RC_OK + if server.lag > G.CONFIG.network.lag_reconnect * 1000: + server.disconnect() + return W.WEECHAT_RC_OK - while server.send_queue: - message = server.send_queue.popleft() - prnt_debug( - DebugType.MESSAGING, - server, ("Timer hook found message of type {t} in queue. Sending " - "out.".format(t=message.__class__.__name__))) + 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 not server.send(message): - # We got an error while sending the last message return the message - # to the queue and exit the loop - server.send_queue.appendleft(message) - break + 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) + server = MatrixServer("matrix_org", config_file._ptr) SERVERS[server.name] = server - W.config_option_set(server.options["address"], "matrix.org", 1) + option = W.config_get(SCRIPT_NAME + ".server." + server.name + ".address") + W.config_option_set(option, "matrix.org", 1) return True @@ -645,6 +1614,6 @@ def send_cb(server_name, file_descriptor): server.send_fd_hook = None if server.send_buffer: - server.try_send(server, server.send_buffer) + server.try_send(server.send_buffer) return W.WEECHAT_RC_OK diff --git a/matrix/uploads.py b/matrix/uploads.py new file mode 100644 index 0000000..a7b4625 --- /dev/null +++ b/matrix/uploads.py @@ -0,0 +1,340 @@ +# -*- 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 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 + + +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 + + +@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"] + + 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/matrix/utf.py b/matrix/utf.py index 6250763..4d71987 100644 --- a/matrix/utf.py +++ b/matrix/utf.py @@ -28,9 +28,13 @@ import sys # pylint: disable=redefined-builtin from builtins import bytes, str -from collections import Mapping, Iterable 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: @@ -39,13 +43,11 @@ from functools import wraps 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 @@ -69,7 +71,8 @@ class WeechatWrapper(object): 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) + buffer, date, tags, message + ) def utf8_decode(function): @@ -92,7 +95,7 @@ def utf8_decode(function): def decode_from_utf8(data): if isinstance(data, bytes): - return data.decode('utf-8') + return data.decode("utf-8") if isinstance(data, str): return data elif isinstance(data, Mapping): @@ -104,7 +107,7 @@ def decode_from_utf8(data): def encode_to_utf8(data): if isinstance(data, str): - return data.encode('utf-8') + return data.encode("utf-8") if isinstance(data, bytes): return data elif isinstance(data, Mapping): diff --git a/matrix/utils.py b/matrix/utils.py index 9d8d437..5b27a49 100644 --- a/matrix/utils.py +++ b/matrix/utils.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- -# Copyright © 2018 Damir Jelić +# 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 @@ -14,15 +15,15 @@ # 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 builtins import str +from __future__ import unicode_literals, division import time -import math +from typing import Any, Dict, List -from matrix.globals import W, SERVERS, OPTIONS +from .globals import W -from matrix.plugin_options import ServerBufferType +if False: + from .server import MatrixServer def key_from_value(dictionary, value): @@ -30,11 +31,6 @@ def key_from_value(dictionary, value): return list(dictionary.keys())[list(dictionary.values()).index(value)] -def prnt_debug(debug_type, server, message): - if debug_type in OPTIONS.debug: - W.prnt(server.server_buffer, message) - - def server_buffer_prnt(server, string): # type: (MatrixServer, str) -> None assert server.server_buffer @@ -44,13 +40,15 @@ def server_buffer_prnt(server, string): def tags_from_line_data(line_data): - # type: (weechat.hdata) -> List[str] + # type: (str) -> List[str] tags_count = W.hdata_get_var_array_size( - W.hdata_get('line_data'), line_data, 'tags_array') + W.hdata_get("line_data"), line_data, "tags_array" + ) tags = [ W.hdata_string( - W.hdata_get('line_data'), line_data, '%d|tags_array' % i) + W.hdata_get("line_data"), line_data, "%d|tags_array" % i + ) for i in range(tags_count) ] @@ -59,38 +57,21 @@ def tags_from_line_data(line_data): def create_server_buffer(server): # type: (MatrixServer) -> None - server.server_buffer = W.buffer_new(server.name, "server_buffer_cb", - server.name, "", "") + 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, "localvar_set_type", 'server') - W.buffer_set(server.server_buffer, "localvar_set_nick", server.user) + 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(server.server_buffer) - - -def server_buffer_merge(buffer): - if OPTIONS.look_server_buf == ServerBufferType.MERGE_CORE: - num = W.buffer_get_integer(W.buffer_search_main(), "number") - W.buffer_unmerge(buffer, num + 1) - W.buffer_merge(buffer, W.buffer_search_main()) - elif OPTIONS.look_server_buf == 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(buffer, num + 1) - if buffer is not first: - W.buffer_merge(buffer, first) - else: - num = W.buffer_get_integer(W.buffer_search_main(), "number") - W.buffer_unmerge(buffer, num + 1) + server.buffer_merge() def server_buffer_set_title(server): @@ -101,18 +82,12 @@ def server_buffer_set_title(server): ip_string = "" title = ("Matrix: {address}:{port}{ip}").format( - address=server.address, port=server.port, ip=ip_string) + address=server.config.address, port=server.config.port, ip=ip_string + ) W.buffer_set(server.server_buffer, "title", title) -def color_for_tags(color): - if color == "weechat.color.chat_nick_self": - option = W.config_get(color) - return W.config_string(option) - return color - - def server_ts_to_weechat(timestamp): # type: (float) -> int date = int(timestamp / 1000) @@ -129,224 +104,65 @@ def shorten_sender(sender): return strip_matrix_server(sender)[1:] -def sender_to_prefix_and_color(room, sender): - if sender in room.users: - user = room.users[sender] - prefix = user.prefix - prefix_color = get_prefix_color(prefix) - return prefix, prefix_color - - return None, None - - -def sender_to_nick_and_color(room, sender): - nick = sender - nick_color_name = "default" - - if sender in room.users: - user = room.users[sender] - nick = (user.display_name if user.display_name else user.name) - nick_color_name = user.nick_color - else: - nick = sender - nick_color_name = W.info_get("nick_color_name", nick) - - return (nick, nick_color_name) - - -def tags_for_message(message_type): - default_tags = { - "message": ["matrix_message", "notify_message", "log1"], - "backlog": - ["matrix_message", "notify_message", "no_log", "no_highlight"] - } - - return default_tags[message_type] - - -def add_event_tags(event_id, nick, color=None, tags=[]): - tags.append("nick_{nick}".format(nick=nick)) - - if color: - tags.append("prefix_nick_{color}".format(color=color_for_tags(color))) - - tags.append("matrix_id_{event_id}".format(event_id=event_id)) - - return tags - - -def sanitize_token(string): - # type: (str) -> str - string = sanitize_string(string) - - if len(string) > 512: - raise ValueError - - if string == "": - raise ValueError - - return string - - -def sanitize_string(string): - # type: (str) -> str - if not isinstance(string, str): - raise TypeError - - # string keys can have empty string values sometimes (e.g. room names that - # got deleted) - if string == "": - return None - - remap = { - ord('\b'): None, - ord('\f'): None, - ord('\n'): None, - ord('\r'): None, - ord('\t'): None, - ord('\0'): None - } - - return string.translate(remap) - - -def sanitize_id(string): - # type: (str) -> str - string = sanitize_string(string) - - if len(string) > 128: - raise ValueError - - if string == "": - raise ValueError - - return string - - -def sanitize_int(number, minimum=None, maximum=None): - # type: (int, int, int) -> int - if not isinstance(number, int): - raise TypeError - - if math.isnan(number): - raise ValueError - - if math.isinf(number): - raise ValueError - - if minimum: - if number < minimum: - raise ValueError - - if maximum: - if number > maximum: - raise ValueError - - return number - - -def sanitize_ts(timestamp): - # type: (int) -> int - return sanitize_int(timestamp, 0) - - -def sanitize_power_level(level): - # type: (int) -> int - return sanitize_int(level, 0, 100) - - -def sanitize_text(string): - # type: (str) -> str - if not isinstance(string, str): - raise TypeError - - # yapf: disable - remap = { - ord('\b'): None, - ord('\f'): None, - ord('\r'): None, - ord('\0'): None - } - # yapf: enable - - return string.translate(remap) - - -def add_user_to_nicklist(buf, user_id, user): - group_name = "999|..." - - if user.power_level >= 100: - group_name = "000|o" - elif user.power_level >= 50: - group_name = "001|h" - elif user.power_level > 0: - group_name = "002|v" - - group = W.nicklist_search_group(buf, "", group_name) - prefix = user.prefix if user.prefix else " " - - # TODO make it configurable so we can use a display name or user_id here - W.nicklist_add_nick(buf, group, user_id, user.nick_color, prefix, - get_prefix_color(user.prefix), 1) - - -def get_prefix_for_level(level): - # type: (int) -> str - if level >= 100: - return "&" - elif level >= 50: - return "@" - elif level > 0: - return "+" - return "" - - -# TODO make this configurable -def get_prefix_color(prefix): - # type: (str) -> str - if prefix == "&": - return "lightgreen" - elif prefix == "@": - return "lightgreen" - elif prefix == "+": - return "yellow" - return "" - - def string_strikethrough(string): return "".join(["{}\u0336".format(c) for c in string]) -def line_pointer_and_tags_from_event(buff, event_id): - # type: (str, str) -> str - own_lines = W.hdata_pointer(W.hdata_get('buffer'), buff, 'own_lines') +def string_color_and_reset(string, color): + """Color string with color, then reset all attributes.""" - 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: - tags = tags_from_line_data(data_pointer) - - message_id = event_id_from_tags(tags) - - if event_id == message_id: - return data_pointer, tags - - line_pointer = W.hdata_move(hdata_line, line_pointer, -1) - - return None, [] + lines = string.split('\n') + lines = ("{}{}{}".format(W.color(color), line, W.color("reset")) + for line in lines) + return "\n".join(lines) -def event_id_from_tags(tags): - # type: (List[str]) -> str - for tag in tags: - if tag.startswith("matrix_id"): - return tag[10:] +def string_color(string, color): + """Color string with color, then reset the color attribute.""" - return "" + 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) diff --git a/tests/buffer_test.py b/tests/buffer_test.py new file mode 100644 index 0000000..e146ac5 --- /dev/null +++ b/tests/buffer_test.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +from matrix.buffer import WeechatChannelBuffer + + +class TestClass(object): + def test_buffer(self): + b = WeechatChannelBuffer("test_buffer_name", "example.org", "alice") + assert b + + def test_buffer_print(self): + b = WeechatChannelBuffer("test_buffer_name", "example.org", "alice") + b.message("alice", "hello world", 0, 0) + assert b diff --git a/tests/color_test.py b/tests/color_test.py index 6b290ff..1c10405 100644 --- a/tests/color_test.py +++ b/tests/color_test.py @@ -7,8 +7,11 @@ from collections import OrderedDict from hypothesis import given from hypothesis.strategies import sampled_from -from matrix.colors import (Formatted, FormattedString, +from matrix.colors import (G, Formatted, FormattedString, color_html_to_weechat, color_weechat_to_html) +from matrix._weechat import MockConfig + +G.CONFIG = MockConfig() html_prism = ("Test") @@ -42,3 +45,14 @@ def test_handle_strikethrough_first(): assert f1.to_weechat() == valid_result assert f2.to_weechat() == valid_result + + +def test_normalize_spaces_in_inline_code(): + """Normalize spaces in inline code blocks. + + Strips leading and trailing spaces and compress consecutive infix spaces. + """ + valid_result = '\x1b[0m* a *\x1b[00m' + + formatted = Formatted.from_input_line('` * a * `') + assert formatted.to_weechat() == valid_result