weechat-matrix/main.py

558 lines
18 KiB
Python
Raw Normal View History

2017-12-30 14:03:03 +01:00
# -*- coding: utf-8 -*-
# Weechat Matrix Protocol Script
# Copyright © 2018 Damir Jelić <poljar@termina.org.uk>
#
# 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.
2017-12-30 14:03:03 +01:00
from __future__ import unicode_literals
import os
2017-12-30 14:03:03 +01:00
import socket
import ssl
2018-02-28 14:36:59 +01:00
import textwrap
2018-01-06 17:12:54 +01:00
# pylint: disable=redefined-builtin
from builtins import str
2018-08-29 19:40:59 +02:00
from itertools import chain
2018-01-06 17:12:54 +01:00
# pylint: disable=unused-import
2018-08-29 19:40:59 +02:00
from typing import Any, AnyStr, Deque, Dict, List, Optional, Set, Text, Tuple
2018-07-20 19:14:32 +02:00
2018-08-29 19:40:59 +02:00
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
2018-08-29 19:40:59 +02:00
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
)
2018-08-29 19:40:59 +02:00
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.
2018-08-29 19:40:59 +02:00
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,
2018-10-02 13:01:15 +02:00
matrix_redact_command_cb, matrix_topic_command_cb,
2018-11-29 17:09:21 +01:00
matrix_olm_command_cb, matrix_devices_command_cb,
2018-12-19 17:56:28 +01:00
matrix_room_command_cb, matrix_uploads_command_cb,
matrix_upload_command_cb)
2018-08-29 19:40:59 +02:00
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)
2018-08-29 19:40:59 +02:00
from matrix.config import (MatrixConfig, config_log_category_cb,
config_log_level_cb, config_server_buffer_cb,
2018-09-03 20:39:16 +02:00
matrix_config_reload_cb, config_pgup_cb)
from matrix.globals import SCRIPT_NAME, SERVERS, W, MAX_EVENTS
2018-08-29 19:40:59 +02:00
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)
2018-08-29 19:40:59 +02:00
from matrix.utf import utf8_decode
from matrix.utils import server_buffer_prnt, server_buffer_set_title
2018-12-19 17:56:28 +01:00
from matrix.uploads import UploadsBuffer, upload_cb
2018-02-21 17:00:11 +01:00
# yapf: disable
WEECHAT_SCRIPT_NAME = SCRIPT_NAME
2018-01-27 16:21:10 +01:00
WEECHAT_SCRIPT_DESCRIPTION = "matrix chat plugin" # type: str
WEECHAT_SCRIPT_AUTHOR = "Damir Jelić <poljar@termina.org.uk>" # type: str
WEECHAT_SCRIPT_VERSION = "0.1" # type: str
WEECHAT_SCRIPT_LICENSE = "ISC" # type: str
2018-02-21 17:00:11 +01:00
# yapf: enable
2017-12-30 14:03:03 +01:00
2018-07-21 19:54:36 +02:00
logger = Logger("matrix-cli")
2018-02-28 14:36:59 +01:00
def print_certificate_info(buff, sock, cert):
cert_pem = ssl.DER_cert_to_PEM_cert(sock.getpeercert(True))
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert_pem)
public_key = x509.get_pubkey()
key_type = ("RSA" if public_key.type() == crypto.TYPE_RSA else "DSA")
key_size = str(public_key.bits())
sha256_fingerprint = x509.digest(n(b"SHA256"))
sha1_fingerprint = x509.digest(n(b"SHA1"))
2018-02-28 14:36:59 +01:00
signature_algorithm = x509.get_signature_algorithm()
key_info = ("key info: {key_type} key {bits} bits, signed using "
"{algo}").format(
key_type=key_type, bits=key_size,
algo=n(signature_algorithm))
2018-02-28 14:36:59 +01:00
validity_info = (" Begins on: {before}\n"
" Expires on: {after}").format(
before=cert["notBefore"], after=cert["notAfter"])
rdns = chain(*cert["subject"])
subject = ", ".join(["{}={}".format(name, value) for name, value in rdns])
rdns = chain(*cert["issuer"])
issuer = ", ".join(["{}={}".format(name, value) for name, value in rdns])
subject = "subject: {sub}, serial number {serial}".format(
sub=subject, serial=cert["serialNumber"])
issuer = "issuer: {issuer}".format(issuer=issuer)
fingerprints = (" SHA1: {}\n"
" SHA256: {}").format(n(sha1_fingerprint),
n(sha256_fingerprint))
2018-02-28 14:36:59 +01:00
wrapper = textwrap.TextWrapper(
initial_indent=" - ", subsequent_indent=" ")
message = ("{prefix}matrix: received certificate\n"
" - certificate info:\n"
"{subject}\n"
"{issuer}\n"
"{key_info}\n"
" - period of validity:\n{validity_info}\n"
" - fingerprints:\n{fingerprints}").format(
prefix=W.prefix("network"),
subject=wrapper.fill(subject),
issuer=wrapper.fill(issuer),
key_info=wrapper.fill(key_info),
validity_info=validity_info,
fingerprints=fingerprints)
W.prnt(buff, message)
2018-01-06 17:12:54 +01:00
def wrap_socket(server, file_descriptor):
2018-02-12 10:56:28 +01:00
# type: (MatrixServer, int) -> None
2018-01-06 17:12:54 +01:00
sock = None # type: socket.socket
2017-12-30 14:03:03 +01:00
2018-02-21 17:00:11 +01:00
temp_socket = socket.fromfd(file_descriptor, socket.AF_INET,
socket.SOCK_STREAM)
2018-01-03 12:14:24 +01:00
# 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
2018-01-23 13:30:42 +01:00
# pylint: disable=protected-access,unidiomatic-typecheck
if type(temp_socket) == socket._socket.socket:
2018-01-07 15:46:18 +01:00
# pylint: disable=no-member
2018-01-06 17:12:54 +01:00
sock = socket._socketobject(_sock=temp_socket)
2017-12-30 14:03:03 +01:00
else:
2018-01-06 17:12:54 +01:00
sock = temp_socket
2017-12-30 14:03:03 +01:00
# fromfd() duplicates the file descriptor but doesn't retain it's blocking
# non-blocking attribute, so mark the socket as non-blocking even though
# weechat already did that for us
2018-01-30 19:29:03 +01:00
sock.setblocking(False)
2018-01-30 14:16:25 +01:00
2018-01-30 19:29:03 +01:00
message = "{prefix}matrix: Doing SSL handshake...".format(
prefix=W.prefix("network"))
W.prnt(server.server_buffer, message)
2018-01-30 14:16:25 +01:00
2018-01-30 19:29:03 +01:00
ssl_socket = server.ssl_context.wrap_socket(
2018-02-21 17:00:11 +01:00
sock, do_handshake_on_connect=False,
server_hostname=server.address) # type: ssl.SSLSocket
2018-01-06 17:12:54 +01:00
2018-01-30 19:29:03 +01:00
server.socket = ssl_socket
2018-01-30 14:16:25 +01:00
2018-01-30 19:29:03 +01:00
try_ssl_handshake(server)
2018-01-30 14:16:25 +01:00
2018-01-30 19:29:03 +01:00
@utf8_decode
def ssl_fd_cb(server_name, file_descriptor):
server = SERVERS[server_name]
if server.ssl_hook:
W.unhook(server.ssl_hook)
server.ssl_hook = None
try_ssl_handshake(server)
return W.WEECHAT_RC_OK
def try_ssl_handshake(server):
2018-02-01 18:02:58 +01:00
sock = server.socket
2018-01-30 19:29:03 +01:00
while True:
try:
2018-02-01 18:02:58 +01:00
sock.do_handshake()
2018-01-30 14:16:25 +01:00
2018-02-01 18:02:58 +01:00
cipher = sock.cipher()
2018-01-30 19:29:03 +01:00
cipher_message = ("{prefix}matrix: Connected using {tls}, and "
"{bit} bit {cipher} cipher suite.").format(
2018-02-01 18:02:58 +01:00
prefix=W.prefix("network"),
tls=cipher[1],
bit=cipher[2],
cipher=cipher[0])
2018-01-30 19:29:03 +01:00
W.prnt(server.server_buffer, cipher_message)
2018-02-28 14:36:59 +01:00
cert = sock.getpeercert()
if cert:
print_certificate_info(server.server_buffer, sock, cert)
2018-01-30 19:29:03 +01:00
finalize_connection(server)
return True
except ssl.SSLWantReadError:
2018-02-21 17:00:11 +01:00
hook = W.hook_fd(server.socket.fileno(), 1, 0, 0, "ssl_fd_cb",
server.name)
2018-01-30 19:29:03 +01:00
server.ssl_hook = hook
return False
except ssl.SSLWantWriteError:
2018-02-21 17:00:11 +01:00
hook = W.hook_fd(server.socket.fileno(), 0, 1, 0, "ssl_fd_cb",
server.name)
2018-01-30 19:29:03 +01:00
server.ssl_hook = hook
return False
except (ssl.SSLError, ssl.CertificateError, socket.error) as error:
try:
str_error = error.reason if error.reason else "Unknown error"
except AttributeError:
str_error = str(error)
message = ("{prefix}Error while doing SSL handshake"
": {error}").format(
2018-02-21 17:00:11 +01:00
prefix=W.prefix("network"), error=str_error)
server_buffer_prnt(server, message)
server_buffer_prnt(
2018-02-21 17:00:11 +01:00
server, ("{prefix}matrix: disconnecting from server..."
).format(prefix=W.prefix("network")))
server.disconnect()
2018-01-30 19:29:03 +01:00
return False
2017-12-30 14:03:03 +01:00
@utf8_decode
2018-01-06 17:12:54 +01:00
def receive_cb(server_name, file_descriptor):
server = SERVERS[server_name]
2017-12-30 14:03:03 +01:00
while True:
try:
data = server.socket.recv(4096)
2017-12-30 14:03:03 +01:00
except ssl.SSLWantReadError:
break
2018-01-06 17:12:54 +01:00
except socket.error as error:
errno = "error" + str(error.errno) + " " if error.errno else ""
str_error = error.strerror if error.strerror else "Unknown error"
str_error = errno + str_error
message = ("{prefix}Error while reading from "
"socket: {error}").format(
2018-02-21 17:00:11 +01:00
prefix=W.prefix("network"), error=str_error)
server_buffer_prnt(server, message)
server_buffer_prnt(
2018-02-21 17:00:11 +01:00
server, ("{prefix}matrix: disconnecting from server..."
2018-07-21 15:15:48 +02:00
).format(prefix=W.prefix("network")))
server.disconnect()
2017-12-30 14:03:03 +01:00
2018-01-06 17:12:54 +01:00
return W.WEECHAT_RC_OK
2017-12-30 14:03:03 +01:00
if not data:
server_buffer_prnt(
server,
"{prefix}matrix: Error while reading from socket".format(
prefix=W.prefix("network")))
server_buffer_prnt(
2018-02-21 17:00:11 +01:00
server, ("{prefix}matrix: disconnecting from server..."
2018-07-21 15:15:48 +02:00
).format(prefix=W.prefix("network")))
server.disconnect()
2017-12-30 14:03:03 +01:00
break
try:
server.client.receive(data)
2018-07-26 11:33:05 +02:00
except (RemoteTransportError, RemoteProtocolError) as e:
server.error(str(e))
server.disconnect()
break
2017-12-30 14:03:03 +01:00
response = server.client.next_response(MAX_EVENTS)
2017-12-30 14:03:03 +01:00
2018-07-21 15:15:48 +02:00
# Check if we need to send some data back
data_to_send = server.client.data_to_send()
if data_to_send:
server.send(data_to_send)
2018-07-20 19:14:32 +02:00
if response:
server.handle_response(response)
2017-12-30 14:03:03 +01:00
break
return W.WEECHAT_RC_OK
2018-01-30 19:29:03 +01:00
def finalize_connection(server):
2018-07-20 19:14:32 +02:00
hook = W.hook_fd(
server.socket.fileno(),
1,
0,
0,
"receive_cb",
server.name
)
2018-01-30 19:29:03 +01:00
server.fd_hook = hook
server.connected = True
server.connecting = False
2018-09-03 19:20:58 +02:00
server.reconnect_delay = 0
2018-07-21 15:15:48 +02:00
negotiated_protocol = (server.socket.selected_alpn_protocol() or
server.socket.selected_npn_protocol())
2018-07-21 15:15:48 +02:00
if negotiated_protocol == "h2":
server.transport_type = TransportType.HTTP2
else:
server.transport_type = TransportType.HTTP
2018-07-21 15:15:48 +02:00
data = server.client.connect(server.transport_type)
2018-07-21 15:15:48 +02:00
server.send(data)
2018-01-30 19:29:03 +01:00
server.login()
2018-01-30 19:29:03 +01:00
2017-12-30 14:03:03 +01:00
@utf8_decode
def connect_cb(data, status, gnutls_rc, sock, error, ip_address):
2018-01-07 16:04:17 +01:00
# pylint: disable=too-many-arguments,too-many-branches
2018-01-09 12:42:06 +01:00
status_value = int(status) # type: int
2018-01-03 12:14:24 +01:00
server = SERVERS[data]
2017-12-30 14:03:03 +01:00
if status_value == W.WEECHAT_HOOK_CONNECT_OK:
2018-01-06 17:12:54 +01:00
file_descriptor = int(sock) # type: int
2018-01-30 19:29:03 +01:00
server.numeric_address = ip_address
server_buffer_set_title(server)
2018-01-06 17:12:54 +01:00
2018-01-30 19:29:03 +01:00
wrap_socket(server, file_descriptor)
2017-12-30 14:03:03 +01:00
return W.WEECHAT_RC_OK
2017-12-30 14:03:03 +01:00
elif status_value == W.WEECHAT_HOOK_CONNECT_ADDRESS_NOT_FOUND:
server.error('{address} not found'.format(address=ip_address))
2017-12-30 14:03:03 +01:00
elif status_value == W.WEECHAT_HOOK_CONNECT_IP_ADDRESS_NOT_FOUND:
server.error('IP address not found')
2017-12-30 14:03:03 +01:00
elif status_value == W.WEECHAT_HOOK_CONNECT_CONNECTION_REFUSED:
server.error('Connection refused')
2017-12-30 14:03:03 +01:00
elif status_value == W.WEECHAT_HOOK_CONNECT_PROXY_ERROR:
server.error('Proxy fails to establish connection to server')
2017-12-30 14:03:03 +01:00
elif status_value == W.WEECHAT_HOOK_CONNECT_LOCAL_HOSTNAME_ERROR:
server.error('Unable to set local hostname')
2017-12-30 14:03:03 +01:00
elif status_value == W.WEECHAT_HOOK_CONNECT_GNUTLS_INIT_ERROR:
server.error('TLS init error')
2017-12-30 14:03:03 +01:00
elif status_value == W.WEECHAT_HOOK_CONNECT_GNUTLS_HANDSHAKE_ERROR:
server.error('TLS Handshake failed')
2017-12-30 14:03:03 +01:00
elif status_value == W.WEECHAT_HOOK_CONNECT_MEMORY_ERROR:
server.error('Not enough memory')
2017-12-30 14:03:03 +01:00
elif status_value == W.WEECHAT_HOOK_CONNECT_TIMEOUT:
server.error('Timeout')
2017-12-30 14:03:03 +01:00
elif status_value == W.WEECHAT_HOOK_CONNECT_SOCKET_ERROR:
server.error('Unable to create socket')
2017-12-30 14:03:03 +01:00
else:
server.error('Unexpected error: {status}'.format(status=status_value))
2017-12-30 14:03:03 +01:00
server.disconnect(reconnect=True)
2017-12-30 14:03:03 +01:00
return W.WEECHAT_RC_OK
@utf8_decode
2018-01-03 12:14:24 +01:00
def room_close_cb(data, buffer):
2018-02-21 17:00:11 +01:00
W.prnt("",
"Buffer '%s' will be closed!" % W.buffer_get_string(buffer, "name"))
2017-12-30 14:03:03 +01:00
return W.WEECHAT_RC_OK
2018-01-03 12:14:24 +01:00
@utf8_decode
def matrix_unload_cb():
2018-08-29 15:35:36 +02:00
for server in SERVERS.values():
server.config.free()
G.CONFIG.free()
2018-07-20 19:14:32 +02:00
# for server in SERVERS.values():
# server.store_olm()
2018-01-03 12:14:24 +01:00
return W.WEECHAT_RC_OK
2018-01-06 17:12:54 +01:00
def autoconnect(servers):
for server in servers.values():
if server.config.autoconnect:
server.connect()
2018-01-06 17:12:54 +01:00
def debug_buffer_close_cb(data, buffer):
2018-08-29 15:35:36 +02:00
G.CONFIG.debug_buffer = ""
return W.WEECHAT_RC_OK
2018-10-02 18:16:09 +02:00
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
2018-07-21 19:54:36 +02:00
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 = ""
2018-08-29 15:35:36 +02:00
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", "")
2018-08-29 15:35:36 +02:00
buf = G.CONFIG.debug_buffer
W.prnt(buf, item)
2018-07-21 19:54:36 +02:00
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
2017-12-30 14:03:03 +01:00
if __name__ == "__main__":
2018-02-21 17:00:11 +01:00
if W.register(WEECHAT_SCRIPT_NAME, WEECHAT_SCRIPT_AUTHOR,
WEECHAT_SCRIPT_VERSION, WEECHAT_SCRIPT_LICENSE,
WEECHAT_SCRIPT_DESCRIPTION, 'matrix_unload_cb', ''):
2017-12-30 14:03:03 +01:00
if not W.mkdir_home("matrix", 0o700):
message = ("{prefix}matrix: Error creating session "
"directory").format(prefix=W.prefix("error"))
W.prnt("", message)
2018-02-05 12:38:45 +01:00
2018-07-21 19:54:36 +02:00
handler = WeechatHandler()
handler.format_string = "{record.channel}: {record.message}"
handler.push_application()
# TODO if this fails we should abort and unload the script.
2018-08-29 15:35:36 +02:00
G.CONFIG = MatrixConfig()
G.CONFIG.read()
2018-01-03 12:14:24 +01:00
hook_commands()
2018-01-29 13:07:49 +01:00
init_bar_items()
2018-01-29 14:01:58 +01:00
init_completion()
W.hook_signal("buffer_switch", "buffer_switch_cb", "")
W.hook_signal("input_text_changed", "typing_notification_cb", "")
2018-01-06 17:12:54 +01:00
if not SERVERS:
2018-08-29 15:35:36 +02:00
create_default_server(G.CONFIG)
2018-01-06 17:12:54 +01:00
autoconnect(SERVERS)