You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1901 lines
62 KiB

# -*- coding: utf-8 -*-
# Copyright © 2018, 2019 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.
from __future__ import unicode_literals
import os
import pprint
import socket
import ssl
import time
import copy
from collections import defaultdict, deque
from atomicwrites import atomic_write
from typing import (
Any,
Deque,
Dict,
Optional,
List,
NamedTuple,
DefaultDict,
Union
)
from uuid import UUID
from nio import (
Api,
HttpClient,
LocalProtocolError,
LoginResponse,
Response,
Rooms,
RoomSendResponse,
RoomSendError,
SyncResponse,
PartialSyncResponse,
ShareGroupSessionResponse,
ShareGroupSessionError,
KeysQueryResponse,
KeysClaimResponse,
DevicesResponse,
UpdateDeviceResponse,
DeleteDevicesAuthResponse,
DeleteDevicesResponse,
TransportType,
RoomMessagesResponse,
EncryptionError,
GroupEncryptionError,
OlmTrustError,
ErrorResponse,
SyncError,
LoginError,
JoinedMembersResponse,
JoinedMembersError,
RoomKeyEvent,
KeyVerificationStart,
KeyVerificationCancel,
KeyVerificationKey,
KeyVerificationMac,
KeyVerificationEvent,
ToDeviceResponse,
ToDeviceError
)
from . import globals as G
from .buffer import OwnAction, OwnMessage, RoomBuffer
from .config import ConfigSection, Option, ServerBufferType
from .globals import SCRIPT_NAME, SERVERS, W, MAX_EVENTS, TYPING_NOTICE_TIMEOUT
from .utf import utf8_decode
from .utils import create_server_buffer, key_from_value, server_buffer_prnt
from .uploads import Upload
from .colors import Formatted, FormattedString, DEFAULT_ATTRIBUTES
try:
from urllib.parse import urlparse
except ImportError:
from urlparse import urlparse
try:
FileNotFoundError # type: ignore
except NameError:
FileNotFoundError = IOError
EncrytpionQueueItem = NamedTuple(
"EncrytpionQueueItem",
[
("message_type", str),
("message", Union[Formatted, Upload]),
],
)
class ServerConfig(ConfigSection):
def __init__(self, server_name, config_ptr):
# type: (str, str) -> None
self._server_name = server_name
self._config_ptr = config_ptr
self._option_ptrs = {} # type: Dict[str, str]
options = [
Option(
"autoconnect",
"boolean",
"",
0,
0,
"off",
(
"automatically connect to the matrix server when weechat "
"is starting"
),
),
Option(
"address",
"string",
"",
0,
0,
"",
"Hostname or IP address for the server",
),
Option(
"port", "integer", "", 0, 65535, "443", "Port for the server"
),
Option(
"proxy",
"string",
"",
0,
0,
"",
("Name of weechat proxy to use (see /help proxy)"),
),
Option(
"ssl_verify",
"boolean",
"",
0,
0,
"on",
("Check that the SSL connection is fully trusted"),
),
Option(
"username", "string", "", 0, 0, "", "Username to use on server"
),
Option(
"password",
"string",
"",
0,
0,
"",
(
"Password for server (note: content is evaluated, see "
"/help eval)"
),
),
Option(
"device_name",
"string",
"",
0,
0,
"Weechat Matrix",
"Device name to use while logging in to the matrix server",
),
Option(
"autoreconnect_delay",
"integer",
"",
0,
86400,
"10",
("Delay (in seconds) before trying to reconnect to server"),
),
]
section = W.config_search_section(config_ptr, "server")
self._ptr = section
for option in options:
option_name = "{server}.{option}".format(
server=self._server_name, option=option.name
)
self._option_ptrs[option.name] = W.config_new_option(
config_ptr,
section,
option_name,
option.type,
option.description,
option.string_values,
option.min,
option.max,
option.value,
option.value,
0,
"",
"",
"matrix_config_server_change_cb",
self._server_name,
"",
"",
)
autoconnect = ConfigSection.option_property("autoconnect", "boolean")
address = ConfigSection.option_property("address", "string")
port = ConfigSection.option_property("port", "integer")
proxy = ConfigSection.option_property("proxy", "string")
ssl_verify = ConfigSection.option_property("ssl_verify", "boolean")
username = ConfigSection.option_property("username", "string")
device_name = ConfigSection.option_property("device_name", "string")
reconnect_delay = ConfigSection.option_property("autoreconnect_delay", "integer")
password = ConfigSection.option_property(
"password", "string", evaluate=True
)
def free(self):
W.config_section_free_options(self._ptr)
class MatrixServer(object):
# pylint: disable=too-many-instance-attributes
def __init__(self, name, config_ptr):
# type: (str, str) -> None
# yapf: disable
self.name = name # type: str
self.user_id = ""
self.device_id = "" # type: str
self.room_buffers = dict() # type: Dict[str, RoomBuffer]
self.buffers = dict() # type: Dict[str, str]
self.server_buffer = None # type: Optional[str]
self.fd_hook = None # type: Optional[str]
self.ssl_hook = None # type: Optional[str]
self.timer_hook = None # type: Optional[str]
self.numeric_address = "" # type: Optional[str]
self._connected = False # type: bool
self.connecting = False # type: bool
self.reconnect_delay = 0 # type: int
self.reconnect_time = None # type: Optional[float]
self.sync_time = None # type: Optional[float]
self.socket = None # type: Optional[ssl.SSLSocket]
self.ssl_context = ssl.create_default_context() # type: ssl.SSLContext
self.transport_type = None # type: Optional[TransportType]
# Enable http2 negotiation on the ssl context.
self.ssl_context.set_alpn_protocols(["h2", "http/1.1"])
try:
self.ssl_context.set_npn_protocols(["h2", "http/1.1"])
except NotImplementedError:
pass
self.address = None
self.homeserver = None
self.client = None
self.access_token = None # type: Optional[str]
self.next_batch = None # type: Optional[str]
self.transaction_id = 0 # type: int
self.lag = 0 # type: int
self.lag_done = False # type: bool
self.busy = False # type: bool
self.send_fd_hook = None # type: Optional[str]
self.send_buffer = b"" # type: bytes
self.device_check_timestamp = None # type: Optional[int]
self.device_deletion_queue = dict() # type: Dict[str, str]
self.encryption_queue = defaultdict(deque) \
# type: DefaultDict[str, Deque[EncrytpionQueueItem]]
self.backlog_queue = dict() # type: Dict[str, str]
self.user_gc_time = time.time() # type: float
self.member_request_list = [] # type: List[str]
self.rooms_with_missing_members = [] # type: List[str]
self.lazy_load_hook = None # type: Optional[str]
self.partial_sync_hook = None # type: Optional[str]
# These flags remember if we made some requests so that we don't
# make them again while we wait on a response, the flags need to be
# cleared when we disconnect.
self.keys_queried = False # type: bool
self.keys_claimed = defaultdict(bool) # type: Dict[str, bool]
self.group_session_shared = defaultdict(bool) # type: Dict[str, bool]
self.ignore_while_sharing = defaultdict(bool)
self.to_device_sent = []
self.config = ServerConfig(self.name, config_ptr)
self._create_session_dir()
# yapf: enable
def _create_session_dir(self):
path = os.path.join("matrix", self.name)
if not W.mkdir_home(path, 0o700):
message = (
"{prefix}matrix: Error creating server session " "directory"
).format(prefix=W.prefix("error"))
W.prnt("", message)
@property
def connected(self):
return self._connected
@connected.setter
def connected(self, value):
self._connected = value
W.bar_item_update("buffer_modes")
W.bar_item_update("matrix_modes")
def get_session_path(self):
home_dir = W.info_get("weechat_dir", "")
return os.path.join(home_dir, "matrix", self.name)
def _load_device_id(self):
file_name = "{}{}".format(self.config.username, ".device_id")
path = os.path.join(self.get_session_path(), file_name)
if not os.path.isfile(path):
return
with open(path, "r") as device_file:
device_id = device_file.readline().rstrip()
if device_id:
self.device_id = device_id
def save_device_id(self):
file_name = "{}{}".format(self.config.username, ".device_id")
path = os.path.join(self.get_session_path(), file_name)
with atomic_write(path, overwrite=True) as device_file:
device_file.write(self.device_id)
@staticmethod
def _parse_url(address, port):
if not address.startswith("http"):
address = "https://{}".format(address)
parsed_url = urlparse(address)
homeserver = parsed_url._replace(
netloc=parsed_url.hostname + ":{}".format(port)
)
return homeserver
def _change_client(self):
homeserver = MatrixServer._parse_url(
self.config.address,
self.config.port
)
self.address = homeserver.hostname
self.homeserver = homeserver
self.client = HttpClient(
homeserver.geturl(),
self.config.username,
self.device_id,
self.get_session_path(),
)
self.client.add_to_device_callback(
self.key_verification_cb,
KeyVerificationEvent
)
def key_verification_cb(self, event):
if isinstance(event, KeyVerificationStart):
self.info_highlight("{user} via {device} has started a key "
"verification process.\n"
"To accept use /olm verification "
"accept {user} {device}".format(
user=event.sender,
device=event.from_device
))
elif isinstance(event, KeyVerificationKey):
sas = self.client.key_verifications.get(event.transaction_id, None)
if not sas:
return
if sas.canceled:
return
device = sas.other_olm_device
emoji = sas.get_emoji()
emojis = [x[0] for x in emoji]
descriptions = [x[1] for x in emoji]
centered_width = 12
def center_emoji(emoji, width):
# Assume each emoji has width 2
emoji_width = 2
# These are emojis that need VARIATION-SELECTOR-16 (U+FE0F) so
# that they are rendered with coloured glyphs. For these, we
# need to add an extra space after them so that they are
# rendered properly in weechat.
variation_selector_emojis = [
'☁️',
'❤️',
'☂️',
'✏️',
'✂️',
'☎️',
'✈️'
]
# Hack to make weechat behave properly when one of the above is
# printed.
if emoji in variation_selector_emojis:
emoji += " "
# This is a trick to account for the fact that emojis are wider
# than other monospace characters.
placeholder = '.' * emoji_width
return placeholder.center(width).replace(placeholder, emoji)
emoji_str = u"".join(center_emoji(e, centered_width)
for e in emojis)
desc = u"".join(d.center(centered_width) for d in descriptions)
short_string = u"\n".join([emoji_str, desc])
self.info_highlight(u"Short authentication string for "
u"{user} via {device}:\n{string}\n"
u"Confirm that the strings match with "
u"/olm verification confirm {user} "
u"{device}".format(
user=device.user_id,
device=device.id,
string=short_string
))
elif isinstance(event, KeyVerificationMac):
try:
sas = self.client.key_verifications[event.transaction_id]
except KeyError:
return
device = sas.other_olm_device
if sas.verified:
self.info_highlight("Device {} of user {} succesfully "
"verified".format(
device.id,
device.user_id
))
elif isinstance(event, KeyVerificationCancel):
self.info_highlight("The interactive device verification with "
"user {} got canceled: {}.".format(
event.sender,
event.reason
))
def update_option(self, option, option_name):
if option_name == "address":
self._change_client()
elif option_name == "port":
self._change_client()
elif option_name == "ssl_verify":
value = W.config_boolean(option)
if value:
self.ssl_context.verify_mode = ssl.CERT_REQUIRED
self.ssl_context.check_hostname = True
else:
self.ssl_context.check_hostname = False
self.ssl_context.verify_mode = ssl.CERT_NONE
elif option_name == "username":
value = W.config_string(option)
self.access_token = ""
self._load_device_id()
if self.client:
self.client.user = value
if self.device_id:
self.client.device_id = self.device_id
else:
pass
def send_or_queue(self, request):
# type: (bytes) -> None
self.send(request)
def try_send(self, message):
# type: (MatrixServer, bytes) -> bool
sock = self.socket
if not sock:
return False
total_sent = 0
message_length = len(message)
while total_sent < message_length:
try:
sent = sock.send(message[total_sent:])
except ssl.SSLWantWriteError:
hook = W.hook_fd(sock.fileno(), 0, 1, 0, "send_cb", self.name)
self.send_fd_hook = hook
self.send_buffer = message[total_sent:]
return True
except socket.error as error:
self._abort_send()
errno = "error" + str(error.errno) + " " if error.errno else ""
strerr = error.strerror if error.strerror else "Unknown reason"
strerr = errno + strerr
error_message = (
"{prefix}Error while writing to " "socket: {error}"
).format(prefix=W.prefix("network"), error=strerr)
server_buffer_prnt(self, error_message)
server_buffer_prnt(
self,
("{prefix}matrix: disconnecting from server...").format(
prefix=W.prefix("network")
),
)
self.disconnect()
return False
if sent == 0:
self._abort_send()
server_buffer_prnt(
self,
"{prefix}matrix: Error while writing to socket".format(
prefix=W.prefix("network")
),
)
server_buffer_prnt(
self,
("{prefix}matrix: disconnecting from server...").format(
prefix=W.prefix("network")
),
)
self.disconnect()
return False
total_sent = total_sent + sent
self._finalize_send()
return True
def _abort_send(self):
self.send_buffer = b""
def _finalize_send(self):
# type: (MatrixServer) -> None
self.send_buffer = b""
def info_highlight(self, message):
buf = ""
if self.server_buffer:
buf = self.server_buffer
msg = "{}{}: {}".format(W.prefix("network"), SCRIPT_NAME, message)
W.prnt_date_tags(buf, 0, "notify_highlight", msg)
def info(self, message):
buf = ""
if self.server_buffer:
buf = self.server_buffer
msg = "{}{}: {}".format(W.prefix("network"), SCRIPT_NAME, message)
W.prnt(buf, msg)
def error(self, message):
buf = ""
if self.server_buffer:
buf = self.server_buffer
msg = "{}{}: {}".format(W.prefix("error"), SCRIPT_NAME, message)
W.prnt(buf, msg)
def send(self, data):
# type: (bytes) -> bool
self.try_send(data)
return True
def reconnect(self):
message = ("{prefix}matrix: reconnecting to server...").format(
prefix=W.prefix("network")
)
server_buffer_prnt(self, message)
self.reconnect_time = None
if not self.connect():
self.schedule_reconnect()
def schedule_reconnect(self):
# type: (MatrixServer) -> None
self.connecting = True
self.reconnect_time = time.time()
if self.reconnect_delay:
self.reconnect_delay = self.reconnect_delay * 2
else:
self.reconnect_delay = self.config.reconnect_delay
message = (
"{prefix}matrix: reconnecting to server in {t} " "seconds"
).format(prefix=W.prefix("network"), t=self.reconnect_delay)
server_buffer_prnt(self, message)
def _close_socket(self):
# type: () -> None
if self.socket:
try:
self.socket.shutdown(socket.SHUT_RDWR)
except socket.error:
pass
try:
self.socket.close()
except OSError:
pass
def disconnect(self, reconnect=True):
# type: (bool) -> None
if self.fd_hook:
W.unhook(self.fd_hook)
self._close_socket()
self.fd_hook = None
self.socket = None
self.connected = False
self.access_token = ""
self.send_buffer = b""
self.transport_type = None
self.member_request_list = []
if self.client:
try:
self.client.disconnect()
except LocalProtocolError:
pass
self.lag = 0
W.bar_item_update("lag")
self.reconnect_time = None
# Clear our request flags.
self.keys_queried = False
self.keys_claimed = defaultdict(bool)
self.group_session_shared = defaultdict(bool)
self.ignore_while_sharing = defaultdict(bool)
self.to_device_sent = []
if self.server_buffer:
message = ("{prefix}matrix: disconnected from server").format(
prefix=W.prefix("network")
)
server_buffer_prnt(self, message)
if reconnect:
self.schedule_reconnect()
else:
self.reconnect_delay = 0
def connect(self):
# type: (MatrixServer) -> int
if not self.config.address or not self.config.port:
message = "{prefix}Server address or port not set".format(
prefix=W.prefix("error")
)
W.prnt("", message)
return False
if not self.config.username or not self.config.password:
message = "{prefix}User or password not set".format(
prefix=W.prefix("error")
)
W.prnt("", message)
return False
if self.connected:
return True
if not self.server_buffer:
create_server_buffer(self)
if not self.timer_hook:
self.timer_hook = W.hook_timer(
1 * 1000, 0, 0, "matrix_timer_cb", self.name
)
ssl_message = " (SSL)" if self.ssl_context.check_hostname else ""
message = (
"{prefix}matrix: Connecting to " "{server}:{port}{ssl}..."
).format(
prefix=W.prefix("network"),
server=self.address,
port=self.config.port,
ssl=ssl_message,
)
W.prnt(self.server_buffer, message)
W.hook_connect(
self.config.proxy,
self.address,
self.config.port,
1,
0,
"",
"connect_cb",
self.name,
)
return True
def schedule_sync(self):
self.sync_time = time.time()
def sync(self, timeout=None, sync_filter=None):
# type: (Optional[int], Optional[Dict[Any, Any]]) -> None
if not self.client:
return
self.sync_time = None
_, request = self.client.sync(timeout, sync_filter)
self.send_or_queue(request)
def login(self):
# type: () -> None
if not self.client:
return
if self.client.logged_in:
msg = (
"{prefix}{script_name}: Already logged in, " "syncing..."
).format(prefix=W.prefix("network"), script_name=SCRIPT_NAME)
W.prnt(self.server_buffer, msg)
timeout = 0 if self.transport_type == TransportType.HTTP else 30000
sync_filter = {
"room": {
"timeline": {"limit": 5000},
"state": {"lazy_load_members": True}
}
}
self.sync(timeout, sync_filter)
return
_, request = self.client.login(
self.config.password, self.config.device_name
)
self.send_or_queue(request)
msg = "{prefix}matrix: Logging in...".format(
prefix=W.prefix("network")
)
W.prnt(self.server_buffer, msg)
def devices(self):
_, request = self.client.devices()
self.send_or_queue(request)
def delete_device(self, device_id, auth=None):
uuid, request = self.client.delete_devices([device_id], auth)
self.device_deletion_queue[uuid] = device_id
self.send_or_queue(request)
return
def rename_device(self, device_id, display_name):
content = {
"display_name": display_name
}
_, request = self.client.update_device(device_id, content)
self.send_or_queue(request)
def room_send_state(self, room_buffer, body, event_type):
_, request = self.client.room_put_state(
room_buffer.room.room_id, event_type, body
)
self.send_or_queue(request)
def room_send_redaction(self, room_buffer, event_id, reason=None):
_, request = self.client.room_redact(
room_buffer.room.room_id, event_id, reason
)
self.send_or_queue(request)
def room_kick(self, room_buffer, user_id, reason=None):
_, request = self.client.room_kick(
room_buffer.room.room_id, user_id, reason
)
self.send_or_queue(request)
def room_invite(self, room_buffer, user_id):
_, request = self.client.room_invite(room_buffer.room.room_id, user_id)
self.send_or_queue(request)
def room_join(self, room_id):
_, request = self.client.join(room_id)
self.send_or_queue(request)
def room_leave(self, room_id):
_, request = self.client.room_leave(room_id)
self.send_or_queue(request)
def room_get_messages(self, room_id):
room_buffer = self.find_room_from_id(room_id)
# We're already fetching old messages
if room_buffer.backlog_pending:
return
if not room_buffer.prev_batch:
return
uuid, request = self.client.room_messages(
room_id,
room_buffer.prev_batch,
limit=10)
room_buffer.backlog_pending = True
self.backlog_queue[uuid] = room_id
self.send_or_queue(request)
def room_send_read_marker(self, room_id, event_id):
"""Send read markers for the provided room.
Args:
room_id(str): the room for which the read markers should
be sent.
event_id(str): the event id where to set the marker
"""
if not self.connected:
return
_, request = self.client.room_read_markers(
room_id,
fully_read_event=event_id,
read_event=event_id)
self.send(request)
def room_send_typing_notice(self, room_buffer):
"""Send a typing notice for the provided room.
Args:
room_buffer(RoomBuffer): the room for which the typing notice needs
to be sent.
"""
if not self.connected:
return
input = room_buffer.weechat_buffer.input
typing_enabled = bool(int(W.string_eval_expression(
G.CONFIG.network.typing_notice_conditions,
{},
{"typing_enabled": str(int(room_buffer.typing_enabled))},
{"type": "condition"}
)))
if not typing_enabled:
return
# Don't send a typing notice if the user is typing in a weechat command
if input.startswith("/") and not input.startswith("//"):
return
# Don't send a typing notice if we only typed a couple of letters.
elif len(input) < 4 and not room_buffer.typing:
return
# If we were typing already and our input bar now has no letters or
# only a couple of letters stop the typing notice.
elif len(input) < 4:
_, request = self.client.room_typing(
room_buffer.room.room_id,
typing_state=False)
room_buffer.typing = False
self.send(request)
return
# Don't send out a typing notice if we already sent one out and it
# didn't expire yet.
if not room_buffer.typing_notice_expired:
return
_, request = self.client.room_typing(
room_buffer.room.room_id,
typing_state=True,
timeout=TYPING_NOTICE_TIMEOUT)
room_buffer.typing = True
self.send(request)
def room_send_upload(
self,
upload
):
"""Send a room message containing the mxc URI of an upload."""
try:
room_buffer = self.find_room_from_id(upload.room_id)
except (ValueError, KeyError):
return True
assert self.client
if room_buffer.room.encrypted:
assert upload.encrypt
content = upload.content
try:
uuid = self.room_send_event(upload.room_id, content)
except (EncryptionError, GroupEncryptionError):
message = EncrytpionQueueItem(upload.msgtype, upload)
self.encryption_queue[upload.room_id].append(message)
return False
attributes = DEFAULT_ATTRIBUTES.copy()
formatted = Formatted([FormattedString(
upload.render,
attributes
)])
own_message = OwnMessage(
self.user_id, 0, "", uuid, upload.room_id, formatted
)
room_buffer.sent_messages_queue[uuid] = own_message
self.print_unconfirmed_message(room_buffer, own_message)
return True
def share_group_session(
self,
room_id,
ignore_missing_sessions=False,
ignore_unverified_devices=False
):
self.ignore_while_sharing[room_id] = ignore_unverified_devices
_, request = self.client.share_group_session(
room_id,
ignore_missing_sessions=ignore_missing_sessions,
ignore_unverified_devices=ignore_unverified_devices
)
self.send(request)
self.group_session_shared[room_id] = True
def room_send_event(
self,
room_id, # type: str
content, # type: Dict[str, str]
event_type="m.room.message", # type: str
ignore_unverified_devices=False, # type: bool
):
# type: (...) -> UUID
assert self.client
try:
uuid, request = self.client.room_send(
room_id, event_type, content
)
self.send(request)
return uuid
except GroupEncryptionError:
try:
if not self.group_session_shared[room_id]:
self.share_group_session(
room_id,
ignore_unverified_devices=ignore_unverified_devices
)
raise
except EncryptionError:
if not self.keys_claimed[room_id]:
_, request = self.client.keys_claim(room_id)
self.keys_claimed[room_id] = True
self.send(request)
raise
def room_send_message(
self,
room_buffer, # type: RoomBuffer
formatted, # type: Formatted
msgtype="m.text", # type: str
ignore_unverified_devices=False, # type: bool
):
# type: (...) -> bool
room = room_buffer.room
assert self.client
content = {"msgtype": msgtype, "body": formatted.to_plain()}
if formatted.is_formatted():
content["format"] = "org.matrix.custom.html"
content["formatted_body"] = formatted.to_html()
try:
uuid = self.room_send_event(
room.room_id,
content,
ignore_unverified_devices=ignore_unverified_devices
)
except (EncryptionError, GroupEncryptionError):
message = EncrytpionQueueItem(msgtype, formatted)
self.encryption_queue[room.room_id].append(message)
return False
if msgtype == "m.emote":
message_class = OwnAction
else:
message_class = OwnMessage
own_message = message_class(
self.user_id, 0, "", uuid, room.room_id, formatted
)
room_buffer.sent_messages_queue[uuid] = own_message
self.print_unconfirmed_message(room_buffer, own_message)
return True
def print_unconfirmed_message(self, room_buffer, message):
"""Print an outoing message before getting a recieve confirmation.
The message is printed out greyed out and only printed out if the
client is configured to do so. The message needs to be later modified
to contain proper coloring, this is done in the
replace_printed_line_by_uuid() method of the RoomBuffer class.
Args:
room_buffer(RoomBuffer): the buffer of the room where the message
needs to be printed out
message(OwnMessages): the message that should be printed out
"""
if G.CONFIG.network.print_unconfirmed_messages:
room_buffer.printed_before_ack_queue.append(message.uuid)
plain_message = message.formatted_message.to_weechat()
plain_message = W.string_remove_color(plain_message, "")
attributes = DEFAULT_ATTRIBUTES.copy()
attributes["fgcolor"] = G.CONFIG.color.unconfirmed_message_fg
attributes["bgcolor"] = G.CONFIG.color.unconfirmed_message_bg
new_formatted = Formatted([FormattedString(
plain_message,
attributes
)])
new_message = copy.copy(message)
new_message.formatted_message = new_formatted
if isinstance(new_message, OwnAction):
room_buffer.self_action(new_message)
elif isinstance(new_message, OwnMessage):
room_buffer.self_message(new_message)
def keys_upload(self):
_, request = self.client.keys_upload()
self.send_or_queue(request)
def keys_query(self):
_, request = self.client.keys_query()
self.keys_queried = True
self.send_or_queue(request)
def get_joined_members(self, room_id):
if not self.connected:
return
if room_id in self.member_request_list:
return
self.member_request_list.append(room_id)
_, request = self.client.joined_members(room_id)
self.send(request)
def _print_message_error(self, message):
server_buffer_prnt(
self,
(
"{prefix}Unhandled {status_code} error, please "
"inform the developers about this."
).format(
prefix=W.prefix("error"), status_code=message.response.status
),
)
server_buffer_prnt(self, pprint.pformat(message.__class__.__name__))
server_buffer_prnt(self, pprint.pformat(message.request.payload))
server_buffer_prnt(self, pprint.pformat(message.response.body))
def handle_own_messages_error(self, response):
room_buffer = self.room_buffers[response.room_id]
if response.uuid not in room_buffer.printed_before_ack_queue:
return
message = room_buffer.sent_messages_queue.pop(response.uuid)
room_buffer.mark_message_as_unsent(response.uuid, message)
room_buffer.printed_before_ack_queue.remove(response.uuid)
def handle_own_messages(self, response):
def send_marker():
if not room_buffer.read_markers_enabled:
return
self.room_send_read_marker(response.room_id, response.event_id)
room_buffer.last_read_event = response.event_id
room_buffer = self.room_buffers[response.room_id]
message = room_buffer.sent_messages_queue.pop(response.uuid, None)
# The message might have been returned in a sync response before we got
# a room send response.
if not message:
return
message.event_id = response.event_id
# We already printed the message, just modify it to contain the proper
# colors and formatting.
if response.uuid in room_buffer.printed_before_ack_queue:
room_buffer.replace_printed_line_by_uuid(response.uuid, message)
room_buffer.printed_before_ack_queue.remove(response.uuid)
send_marker()
return
if isinstance(message, OwnAction):
room_buffer.self_action(message)
send_marker()
return
if isinstance(message, OwnMessage):
room_buffer.self_message(message)
send_marker()
return
raise NotImplementedError(
"Unsupported message of type {}".format(type(message))
)
def handle_backlog_response(self, response):
room_id = self.backlog_queue.pop(response.uuid)
room_buffer = self.find_room_from_id(room_id)
room_buffer.handle_backlog(response)
def handle_devices_response(self, response):
if not response.devices:
m = "{}{}: No devices found for this account".format(
W.prefix("error"),
SCRIPT_NAME)
W.prnt(self.server_buffer, m)
header = (W.prefix("network") + SCRIPT_NAME + ": Devices for "
"server {}{}{}:\n"
" Device ID Device Name "
"Last Seen").format(
W.color("chat_server"),
self.name,
W.color("reset")
)
W.prnt(self.server_buffer, header)
lines = []
for device in response.devices:
last_seen_date = ("?" if not device.last_seen_date else
device.last_seen_date.strftime("%Y/%m/%d %H:%M"))
last_seen = "{ip} @ {date}".format(
ip=device.last_seen_ip or "?",
date=last_seen_date
)
device_color = ("chat_self" if device.id == self.device_id else
W.info_get("nick_color_name", device.id))
bold = W.color("bold") if device.id == self.device_id else ""
line = " {}{}{:<18}{}{:<34}{:<}".format(
bold,
W.color(device_color),
device.id,
W.color("resetcolor"),
device.display_name or "",
last_seen
)
lines.append(line)
W.prnt(self.server_buffer, "\n".join(lines))
def _handle_login(self, response):
self.access_token = response.access_token
self.user_id = response.user_id
self.client.access_token = response.access_token
self.device_id = response.device_id
self.save_device_id()
message = "{prefix}matrix: Logged in as {user}".format(
prefix=W.prefix("network"), user=self.user_id
)
W.prnt(self.server_buffer, message)
if not self.client.olm_account_shared:
self.keys_upload()
sync_filter = {
"room": {
"timeline": {
"limit": G.CONFIG.network.max_initial_sync_events
},
"state": {"lazy_load_members": True}
}
}
self.sync(timeout=0, sync_filter=sync_filter)
def _handle_room_info(self, response):
for room_id, info in response.rooms.invite.items():
room = self.client.invited_rooms.get(room_id, None)
if room:
if room.inviter:
inviter_msg = " by {}{}".format(
W.color("chat_nick_other"), room.inviter
)
else:
inviter_msg = ""
self.info_highlight(
"You have been invited to {} {}({}{}{}){}"
"{}".format(
room.display_name,
W.color("chat_delimiters"),
W.color("chat_channel"),
room_id,
W.color("chat_delimiters"),
W.color("reset"),
inviter_msg,
)
)
else:
self.info_highlight("You have been invited to {}.".format(
room_id
))
for room_id, info in response.rooms.leave.items():
if room_id not in self.buffers:
continue
room_buffer = self.find_room_from_id(room_id)
room_buffer.handle_left_room(info)
for room_id, info in response.rooms.join.items():
if room_id not in self.buffers:
self.create_room_buffer(room_id, info.timeline.prev_batch)
room_buffer = self.find_room_from_id(room_id)
room_buffer.handle_joined_room(info)
def add_unhandled_users(self, rooms, n):
# type: (List[RoomBuffer], int) -> bool
total_users = 0
while total_users <= n:
try:
room_buffer = rooms.pop()
except IndexError:
return False
handled_users = 0
users = room_buffer.unhandled_users
for user_id in users:
room_buffer.add_user(user_id, 0, True)
handled_users += 1
total_users += 1
if total_users >= n:
room_buffer.unhandled_users = users[handled_users:]
rooms.append(room_buffer)
return True
room_buffer.unhandled_users = []
return False
def _hook_lazy_user_adding(self):
if not self.lazy_load_hook:
hook = W.hook_timer(1 * 1000, 0, 0,
"matrix_load_users_cb", self.name)
self.lazy_load_hook = hook
def decrypt_printed_messages(self, key_event):
"""Decrypt already printed messages and send them to the buffer"""
try:
room_buffer = self.find_room_from_id(key_event.room_id)
except KeyError:
return
decrypted_events = []
for undecrypted_event in room_buffer.undecrypted_events:
if undecrypted_event.session_id != key_event.session_id:
continue
event = self.client.decrypt_event(undecrypted_event)
if event:
decrypted_events.append((undecrypted_event, event))
for event_pair in decrypted_events:
undecrypted_event, event = event_pair
room_buffer.undecrypted_events.remove(undecrypted_event)
room_buffer.replace_undecrypted_line(event)
def start_verification(self, device):
_, request = self.client.start_key_verification(device)
self.send(request)
self.info("Starting an interactive device verification with "
"{} {}".format(device.user_id, device.id))
def accept_sas(self, sas):
_, request = self.client.accept_key_verification(sas.transaction_id)
self.send(request)
def cancel_sas(self, sas):
_, request = self.client.cancel_key_verification(sas.transaction_id)
self.send(request)
def to_device(self, message):
_, request = self.client.to_device(message)
self.send(request)
def confirm_sas(self, sas):
_, request = self.client.confirm_short_auth_string(sas.transaction_id)
self.send(request)
device = sas.other_olm_device
if sas.verified:
self.info("Device {} of user {} succesfully verified".format(
device.id,
device.user_id
))
else:
self.info("Waiting for {} to confirm...".format(device.user_id))
def _handle_sync(self, response):
# we got the same batch again, nothing to do
if self.next_batch == response.next_batch:
self.schedule_sync()
return
self._handle_room_info(response)
for event in response.to_device_events:
if isinstance(event, RoomKeyEvent):
message = {
"sender": event.sender,
"sender_key": event.sender_key,
"room_id": event.room_id,
"session_id": event.session_id,
"algorithm": event.algorithm,
"server": self.name,
}
W.hook_hsignal_send("matrix_room_key_received", message)
# TODO try to decrypt some cached undecrypted messages with the
# new key
# self.decrypt_printed_messages(event)
# Full sync response handle everything.
if isinstance(response, SyncResponse):
if self.client.should_upload_keys:
self.keys_upload()
if self.client.should_query_keys and not self.keys_queried:
self.keys_query()
for room_buffer in self.room_buffers.values():
# It's our initial sync, we need to fetch room members, so add
# the room to the missing members queue.
if not self.next_batch:
if (not G.CONFIG.network.lazy_load_room_users
or room_buffer.room.encrypted):
self.rooms_with_missing_members.append(
room_buffer.room.room_id
)
if room_buffer.unhandled_users:
self._hook_lazy_user_adding()
break
self.next_batch = response.next_batch
self.schedule_sync()
W.bar_item_update("matrix_typing_notice")
if self.rooms_with_missing_members:
self.get_joined_members(self.rooms_with_missing_members.pop())
else:
if not self.partial_sync_hook:
hook = W.hook_timer(1 * 100, 0, 0, "matrix_partial_sync_cb",
self.name)
self.partial_sync_hook = hook
self.busy = True
W.bar_item_update("buffer_modes")
W.bar_item_update("matrix_modes")
def handle_delete_device_auth(self, response):
device_id = self.device_deletion_queue.pop(response.uuid, None)
if not device_id:
return
for flow in response.flows:
if "m.login.password" in flow["stages"]:
session = response.session
auth = {
"type": "m.login.password",
"session": session,
"user": self.client.user_id,
"password": self.config.password
}
self.delete_device(device_id, auth)
return
self.error("No supported auth method for device deletion found.")
def handle_error_response(self, response):
self.error("Error: {}".format(str(response)))
if isinstance(response, (SyncError, LoginError)):
self.disconnect()
elif isinstance(response, JoinedMembersError):
self.rooms_with_missing_members.append(response.room_id)
self.get_joined_members(self.rooms_with_missing_members.pop())
elif isinstance(response, RoomSendError):
self.handle_own_messages_error(response)
elif isinstance(response, ShareGroupSessionError):
self.group_session_shared[response.room_id] = False
self.share_group_session(
response.room_id,
False,
self.ignore_while_sharing[response.room_id]
)
elif isinstance(response, ToDeviceError):
try:
self.to_device_sent.remove(response.to_device_message)
except ValueError:
pass
def handle_response(self, response):
# type: (Response) -> None
response_lag = response.elapsed
current_lag = 0
if self.client:
current_lag = self.client.lag
if response_lag >= current_lag:
self.lag = response_lag * 1000
self.lag_done = True
W.bar_item_update("lag")
if isinstance(response, ErrorResponse):
self.handle_error_response(response)
elif isinstance(response, ToDeviceResponse):
try:
self.to_device_sent.remove(response.to_device_message)
except ValueError:
pass
elif isinstance(response, LoginResponse):
self._handle_login(response)
elif isinstance(response, (SyncResponse, PartialSyncResponse)):
self._handle_sync(response)
elif isinstance(response, RoomSendResponse):
self.handle_own_messages(response)
elif isinstance(response, RoomMessagesResponse):
self.handle_backlog_response(response)
elif isinstance(response, DevicesResponse):
self.handle_devices_response(response)
elif isinstance(response, UpdateDeviceResponse):
self.info("Device name successfully updated")
elif isinstance(response, DeleteDevicesAuthResponse):
self.handle_delete_device_auth(response)
elif isinstance(response, DeleteDevicesResponse):
self.info("Device successfully deleted")
elif isinstance(response, KeysQueryResponse):
self.keys_queried = False
W.bar_item_update("buffer_modes")
W.bar_item_update("matrix_modes")
for user_id, device_dict in response.changed.items():
for device in device_dict.values():
message = {
"user_id": user_id,
"device_id": device.id,
"ed25519": device.ed25519,
"curve25519": device.curve25519,
"deleted": str(device.deleted)
}
W.hook_hsignal_send("matrix_device_changed", message)
elif isinstance(response, JoinedMembersResponse):
self.member_request_list.remove(response.room_id)
room_buffer = self.room_buffers[response.room_id]
users = [user.user_id for user in response.members]
# Don't add the users directly use the lazy load hook.
room_buffer.unhandled_users += users
self._hook_lazy_user_adding()
room_buffer.members_fetched = True
room_buffer.update_buffer_name()
# Fetch the users for the next room.
if self.rooms_with_missing_members:
self.get_joined_members(self.rooms_with_missing_members.pop())
# We are done adding all the users, do a full key query now since
# the client knows all the encrypted room members.
else:
if self.client.should_query_keys and not self.keys_queried:
self.keys_query()
elif isinstance(response, KeysClaimResponse):
self.keys_claimed[response.room_id] = False
try:
self.share_group_session(
response.room_id,
True,
self.ignore_while_sharing[response.room_id]
)
except OlmTrustError as e:
m = ("Untrusted devices found in room: {}".format(e))
room_buffer = self.find_room_from_id(response.room_id)
room_buffer.error(m)
try:
item = self.encryption_queue[response.room_id][0]
if item.message_type not in ["m.file", "m.video",
"m.audio", "m.image"]:
room_buffer.last_message = item.message
except IndexError:
pass
self.encryption_queue[response.room_id].clear()
return
elif isinstance(response, ShareGroupSessionResponse):
room_id = response.room_id
self.group_session_shared[response.room_id] = False
ignore_unverified = self.ignore_while_sharing[response.room_id]
self.ignore_while_sharing[response.room_id] = False
room_buffer = self.room_buffers[room_id]
while self.encryption_queue[room_id]:
item = self.encryption_queue[room_id].popleft()
try:
if item.message_type in [
"m.file",
"m.video",
"m.audio",
"m.image"
]:
ret = self.room_send_upload(item.message)
else:
ret = self.room_send_message(
room_buffer,
item.message,
item.message_type,
ignore_unverified_devices=ignore_unverified
)
if not ret:
self.encryption_queue[room_id].pop()
self.encryption_queue[room_id].appendleft(message)
break
except OlmTrustError:
self.encryption_queue[room_id].clear()
# If the item is a normal user message store it in the
# buffer to enable the send-anyways functionality.
if item.message_type not in ["m.file", "m.video",
"m.audio", "m.image"]:
room_buffer.last_message = item.message
break
def create_room_buffer(self, room_id, prev_batch):
room = self.client.rooms[room_id]
buf = RoomBuffer(room, self.name, self.homeserver, prev_batch)
if room.members_synced:
buf.members_fetched = True
self.room_buffers[room_id] = buf
self.buffers[room_id] = buf.weechat_buffer._ptr
def find_room_from_ptr(self, pointer):
try:
room_id = key_from_value(self.buffers, pointer)
room_buffer = self.room_buffers[room_id]
return room_buffer
except (ValueError, KeyError):
return None
def find_room_from_id(self, room_id):
room_buffer = self.room_buffers[room_id]
return room_buffer
def garbage_collect_users(self):
""" Remove inactive users.
This tries to keep the number of users added to the nicklist less than
the configuration option matrix.network.max_nicklist_users. It
removes users that have not been active for a day until there are
less than max_nicklist_users or no users are left for removal.
It never removes users that have a bigger power level than the
default one.
This function is run every hour by the server timer callback"""
now = time.time()
self.user_gc_time = now
def day_passed(t1, t2):
return (t2 - t1) > 86400
for room_buffer in self.room_buffers.values():
to_remove = max(
(len(room_buffer.displayed_nicks) -
G.CONFIG.network.max_nicklist_users),
0
)
if not to_remove:
continue
removed = 0
removed_user_ids = []
for user_id, nick in room_buffer.displayed_nicks.items():
user = room_buffer.weechat_buffer.users[nick]
if (not user.speaking_time or
day_passed(user.speaking_time, now)):
room_buffer.weechat_buffer.part(nick, 0, False)
removed_user_ids.append(user_id)
removed += 1
if removed >= to_remove:
break
for user_id in removed_user_ids:
del room_buffer.displayed_nicks[user_id]
def buffer_merge(self):
if not self.server_buffer:
return
buf = self.server_buffer
if G.CONFIG.look.server_buffer == ServerBufferType.MERGE_CORE:
num = W.buffer_get_integer(W.buffer_search_main(), "number")
W.buffer_unmerge(buf, num + 1)
W.buffer_merge(buf, W.buffer_search_main())
elif G.CONFIG.look.server_buffer == ServerBufferType.MERGE:
if SERVERS:
first = None
for server in SERVERS.values():
if server.server_buffer:
first = server.server_buffer
break
if first:
num = W.buffer_get_integer(
W.buffer_search_main(), "number"
)
W.buffer_unmerge(buf, num + 1)
if buf is not first:
W.buffer_merge(buf, first)
else:
num = W.buffer_get_integer(W.buffer_search_main(), "number")
W.buffer_unmerge(buf, num + 1)
@utf8_decode
def matrix_config_server_read_cb(
data, config_file, section, option_name, value
):
return_code = W.WEECHAT_CONFIG_OPTION_SET_ERROR
if option_name:
server_name, option = option_name.rsplit(".", 1)
server = None
if server_name in SERVERS:
server = SERVERS[server_name]
else:
server = MatrixServer(server_name, config_file)
SERVERS[server.name] = server
# Ignore invalid options
if option in server.config._option_ptrs:
return_code = W.config_option_set(
server.config._option_ptrs[option], value, 1
)
# TODO print out error message in case of erroneous return_code
return return_code
@utf8_decode
def matrix_config_server_write_cb(data, config_file, section_name):
if not W.config_write_line(config_file, section_name, ""):
return W.WECHAT_CONFIG_WRITE_ERROR
for server in SERVERS.values():
for option in server.config._option_ptrs.values():
if not W.config_write_option(config_file, option):
return W.WECHAT_CONFIG_WRITE_ERROR
return W.WEECHAT_CONFIG_WRITE_OK
@utf8_decode
def matrix_config_server_change_cb(server_name, option):
# type: (str, str) -> int
server = SERVERS[server_name]
option_name = None
# The function config_option_get_string() is used to get differing
# properties from a config option, sadly it's only available in the plugin
# API of weechat.
option_name = key_from_value(server.config._option_ptrs, option)
server.update_option(option, option_name)
return 1
@utf8_decode
def matrix_partial_sync_cb(server_name, remaining_calls):
start = time.time()
server = SERVERS[server_name]
W.unhook(server.partial_sync_hook)
server.partial_sync_hook = None
response = server.client.next_response(MAX_EVENTS)
while response:
server.handle_response(response)
current = time.time()
if current - start >= 0.1:
break
response = server.client.next_response(MAX_EVENTS)
if not server.partial_sync_hook:
server.busy = False
W.bar_item_update("buffer_modes")
W.bar_item_update("matrix_modes")
return W.WEECHAT_RC_OK
@utf8_decode
def matrix_load_users_cb(server_name, remaining_calls):
server = SERVERS[server_name]
start = time.time()
rooms = [x for x in server.room_buffers.values() if x.unhandled_users]
while server.add_unhandled_users(rooms, 100):
current = time.time()
if current - start >= 0.1:
return W.WEECHAT_RC_OK
# We are done adding users, we can unhook now.
W.unhook(server.lazy_load_hook)
server.lazy_load_hook = None
return W.WEECHAT_RC_OK
@utf8_decode
def matrix_timer_cb(server_name, remaining_calls):
server = SERVERS[server_name]
current_time = time.time()
if (
(not server.connected)
and server.reconnect_time
and current_time >= (server.reconnect_time + server.reconnect_delay)
):
server.reconnect()
return W.WEECHAT_RC_OK
if not server.connected:
return W.WEECHAT_RC_OK
# check lag, disconnect if it's too big
server.lag = server.client.lag * 1000
server.lag_done = False
W.bar_item_update("lag")
if server.lag > G.CONFIG.network.lag_reconnect * 1000:
server.disconnect()
return W.WEECHAT_RC_OK
for i, message in enumerate(server.client.outgoing_to_device_messages):
if i >= 5:
break
if message in server.to_device_sent:
continue
server.to_device(message)
server.to_device_sent.append(message)
if server.sync_time and current_time > server.sync_time:
timeout = 0 if server.transport_type == TransportType.HTTP else 30000
sync_filter = {
"room": {
"timeline": {"limit": 5000},
"state": {"lazy_load_members": True}
}
}
server.sync(timeout, sync_filter)
if current_time > (server.user_gc_time + 3600):
server.garbage_collect_users()
return W.WEECHAT_RC_OK
def create_default_server(config_file):
server = MatrixServer("matrix_org", config_file._ptr)
SERVERS[server.name] = server
option = W.config_get(SCRIPT_NAME + ".server." + server.name + ".address")
W.config_option_set(option, "matrix.org", 1)
return True
@utf8_decode
def send_cb(server_name, file_descriptor):
# type: (str, int) -> int
server = SERVERS[server_name]
if server.send_fd_hook:
W.unhook(server.send_fd_hook)
server.send_fd_hook = None
if server.send_buffer:
server.try_send(server.send_buffer)
return W.WEECHAT_RC_OK