dotfiles/weechat/python/matrix/buffer.py

1811 lines
59 KiB
Python
Raw Normal View History

2021-07-07 21:59:54 -05:00
# -*- coding: utf-8 -*-
# Weechat Matrix Protocol Script
# 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 time
import attr
import pprint
from builtins import super
from functools import partial
from collections import deque
from typing import Dict, List, NamedTuple, Optional, Set
from uuid import UUID
from nio import (
Api,
PowerLevelsEvent,
RedactedEvent,
RedactionEvent,
RoomAliasEvent,
RoomEncryptionEvent,
RoomMemberEvent,
RoomMessage,
RoomMessageEmote,
RoomMessageMedia,
RoomEncryptedMedia,
RoomMessageNotice,
RoomMessageText,
RoomMessageUnknown,
RoomNameEvent,
RoomTopicEvent,
MegolmEvent,
Event,
OlmTrustError,
UnknownEvent,
FullyReadEvent,
BadEvent,
UnknownBadEvent,
)
from . import globals as G
from .colors import Formatted
from .config import RedactType, NewChannelPosition
from .globals import SCRIPT_NAME, SERVERS, W, TYPING_NOTICE_TIMEOUT
from .utf import utf8_decode
from .message_renderer import Render
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
if not server.client.logged_in:
room_buffer.error("You are not logged in.")
return W.WEECHAT_RC_ERROR
data = W.string_input_for_buffer(input_data)
if not data:
data = input_data
formatted_data = Formatted.from_input_line(data)
try:
server.room_send_message(room_buffer, formatted_data, "m.text")
room_buffer.last_message = None
except OlmTrustError as e:
if (G.CONFIG.network.resending_ignores_devices
and room_buffer.last_message):
room_buffer.error("Ignoring unverified devices.")
if (room_buffer.last_message.to_weechat() ==
formatted_data.to_weechat()):
server.room_send_message(room_buffer, formatted_data, "m.text",
ignore_unverified_devices=True)
room_buffer.last_message = None
else:
# If the item is a normal user message store it in the
# buffer to enable the send-anyways functionality.
room_buffer.error("Untrusted devices found in room: {}".format(e))
room_buffer.last_message = formatted_data
return W.WEECHAT_RC_OK
@utf8_decode
def room_buffer_close_cb(server_name, buffer):
server = SERVERS[server_name]
room_buffer = server.find_room_from_ptr(buffer)
if room_buffer:
room_id = room_buffer.room.room_id
server.buffers.pop(room_id, None)
server.room_buffers.pop(room_id, None)
return W.WEECHAT_RC_OK
class WeechatUser(object):
def __init__(self, nick, host=None, prefix="", join_time=None):
# type: (str, str, str, int) -> None
self.nick = nick
self.host = host
self.prefix = prefix
self.color = W.info_get("nick_color_name", nick)
self.join_time = join_time or time.time()
self.speaking_time = None # type: Optional[int]
def update_speaking_time(self, new_time=None):
self.speaking_time = new_time or time.time()
@property
def joined_recently(self):
# TODO make the delay configurable
delay = 30
limit = time.time() - (delay * 60)
return self.join_time < limit
@property
def spoken_recently(self):
if not self.speaking_time:
return False
# TODO make the delay configurable
delay = 5
limit = time.time() - (delay * 60)
return self.speaking_time < limit
class RoomUser(WeechatUser):
def __init__(self, nick, user_id=None, power_level=0, join_time=None):
# type: (str, str, int, int) -> None
prefix = self._get_prefix(power_level)
super().__init__(nick, user_id, prefix, join_time)
@property
def power_level(self):
# This shouldn't be used since it's a lossy function. It's only here
# for the setter
if self.prefix == "&":
return 100
if self.prefix == "@":
return 50
if self.prefix == "+":
return 10
return 0
@power_level.setter
def power_level(self, level):
self.prefix = self._get_prefix(level)
@staticmethod
def _get_prefix(power_level):
# type: (int) -> str
if power_level >= 100:
return "&"
if power_level >= 50:
return "@"
if power_level > 0:
return "+"
return ""
class WeechatChannelBuffer(object):
tags = {
"message": [SCRIPT_NAME + "_message", "notify_message", "log1"],
"message_private": [
SCRIPT_NAME + "_message",
"notify_private",
"log1"
],
"self_message": [
SCRIPT_NAME + "_message",
"notify_none",
"no_highlight",
"self_msg",
"log1",
],
"action": [
SCRIPT_NAME + "_message",
SCRIPT_NAME + "_action",
"notify_message",
"log1",
],
"action_private": [
SCRIPT_NAME + "_message",
SCRIPT_NAME + "_action",
"notify_private",
"log1",
],
"notice": [SCRIPT_NAME + "_notice", "notify_message", "log1"],
"old_message": [
SCRIPT_NAME + "_message",
"notify_message",
"no_log",
"no_highlight",
],
"join": [SCRIPT_NAME + "_join", "log4"],
"part": [SCRIPT_NAME + "_leave", "log4"],
"kick": [SCRIPT_NAME + "_kick", "log4"],
"invite": [SCRIPT_NAME + "_invite", "log4"],
"topic": [SCRIPT_NAME + "_topic", "log3"],
}
membership_messages = {
"join": "has joined",
"part": "has left",
"kick": "has been kicked from",
"invite": "has been invited to",
}
class Line(object):
def __init__(self, pointer):
self._ptr = pointer
@property
def _hdata(self):
return W.hdata_get("line_data")
@property
def prefix(self):
return W.hdata_string(self._hdata, self._ptr, "prefix")
@prefix.setter
def prefix(self, new_prefix):
new_data = {"prefix": new_prefix}
W.hdata_update(self._hdata, self._ptr, new_data)
@property
def message(self):
return W.hdata_string(self._hdata, self._ptr, "message")
@message.setter
def message(self, new_message):
# type: (str) -> None
new_data = {"message": new_message}
W.hdata_update(self._hdata, self._ptr, new_data)
@property
def tags(self):
tags_count = W.hdata_get_var_array_size(
self._hdata, self._ptr, "tags_array"
)
tags = [
W.hdata_string(self._hdata, self._ptr, "%d|tags_array" % i)
for i in range(tags_count)
]
return tags
@tags.setter
def tags(self, new_tags):
# type: (List[str]) -> None
new_data = {"tags_array": ",".join(new_tags)}
W.hdata_update(self._hdata, self._ptr, new_data)
@property
def date(self):
# type: () -> int
return W.hdata_time(self._hdata, self._ptr, "date")
@date.setter
def date(self, new_date):
# type: (int) -> None
new_data = {"date": str(new_date)}
W.hdata_update(self._hdata, self._ptr, new_data)
@property
def date_printed(self):
# type: () -> int
return W.hdata_time(self._hdata, self._ptr, "date_printed")
@date_printed.setter
def date_printed(self, new_date):
# type: (int) -> None
new_data = {"date_printed": str(new_date)}
W.hdata_update(self._hdata, self._ptr, new_data)
@property
def highlight(self):
# type: () -> bool
return bool(W.hdata_char(self._hdata, self._ptr, "highlight"))
def update(
self,
date=None,
date_printed=None,
tags=None,
prefix=None,
message=None,
highlight=None,
):
new_data = {}
if date is not None:
new_data["date"] = str(date)
if date_printed is not None:
new_data["date_printed"] = str(date_printed)
if tags is not None:
new_data["tags_array"] = ",".join(tags)
if prefix is not None:
new_data["prefix"] = prefix
if message is not None:
new_data["message"] = message
if highlight is not None:
new_data["highlight"] = highlight
if new_data:
W.hdata_update(self._hdata, self._ptr, new_data)
def __init__(self, name, server_name, user):
# type: (str, str, str) -> None
# Previous buffer num before create
cur_num = W.buffer_get_integer(W.current_buffer(), "number")
self._ptr = W.buffer_new(
name,
"room_buffer_input_cb",
server_name,
"room_buffer_close_cb",
server_name,
)
new_channel_position = G.CONFIG.look.new_channel_position
if new_channel_position == NewChannelPosition.NONE:
pass
elif new_channel_position == NewChannelPosition.NEXT:
self.number = cur_num + 1
elif new_channel_position == NewChannelPosition.NEAR_SERVER:
server = G.SERVERS[server_name]
last_similar_buffer_num = max(
(room.weechat_buffer.number for room
in server.room_buffers.values()),
default=W.buffer_get_integer(server.server_buffer, "number")
)
self.number = last_similar_buffer_num + 1
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 num_lines(self):
own_lines = W.hdata_pointer(self._hdata, self._ptr, "own_lines")
return W.hdata_integer(W.hdata_get("lines"), own_lines, "lines_count")
@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)
def info(self, string):
message = "{prefix}{script}: {message}".format(
prefix=W.prefix("network"), 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
return G.CONFIG.color.nick_prefixes.get(prefix, "")
def _add_user_to_nicklist(self, user):
# type: (WeechatUser) -> None
nick_pointer = W.nicklist_search_nick(self._ptr, "", user.nick)
if not nick_pointer:
group = W.nicklist_search_group(
self._ptr, "", self._get_nicklist_group(user)
)
prefix = user.prefix if user.prefix else " "
W.nicklist_add_nick(
self._ptr,
group,
user.nick,
user.color,
prefix,
self._get_prefix_color(user.prefix),
1,
)
def _membership_message(self, user, message_type):
# type: (WeechatUser, str) -> str
action_color = "green" if message_type in ("join", "invite") else "red"
prefix = "join" if message_type in ("join", "invite") else "quit"
membership_message = self.membership_messages[message_type]
message = (
"{prefix}{color}{author}{ncolor} "
"{del_color}({host_color}{host}{del_color})"
"{action_color} {message} "
"{channel_color}{room}{ncolor}"
).format(
prefix=W.prefix(prefix),
color=W.color(user.color),
author=user.nick,
ncolor=W.color("reset"),
del_color=W.color("chat_delimiters"),
host_color=W.color("chat_host"),
host=user.host,
action_color=W.color(action_color),
message=membership_message,
channel_color=W.color("chat_channel"),
room=self.short_name,
)
return message
def join(self, user, date, message=True, extra_tags=None):
# type: (WeechatUser, int, Optional[bool], Optional[List[str]]) -> None
self._add_user_to_nicklist(user)
self.users[user.nick] = user
if len(self.users) > 2:
W.buffer_set(self._ptr, "localvar_set_type", "channel")
if message:
tags = self._message_tags(user, "join")
msg = self._membership_message(user, "join")
# TODO add a option to disable smart filters
tags.append(SCRIPT_NAME + "_smart_filter")
self.print_date_tags(msg, date, tags)
self.add_smart_filtered_nick(user.nick)
def invite(self, nick, date, extra_tags=None):
# type: (str, int, Optional[List[str]]) -> None
user = self._get_user(nick)
tags = self._message_tags(user, "invite")
message = self._membership_message(user, "invite")
self.print_date_tags(message, date, tags + (extra_tags or []))
def remove_user_from_nicklist(self, user):
# type: (WeechatUser) -> None
nick_pointer = W.nicklist_search_nick(self._ptr, "", user.nick)
if nick_pointer:
W.nicklist_remove_nick(self._ptr, nick_pointer)
def _leave(self, nick, date, message, leave_type, extra_tags=None):
# type: (str, int, bool, str, List[str]) -> None
user = self._get_user(nick)
self.remove_user_from_nicklist(user)
if len(self.users) <= 2:
W.buffer_set(self._ptr, "localvar_set_type", "private")
if message:
tags = self._message_tags(user, leave_type)
# TODO make this configurable
if not user.spoken_recently:
tags.append(SCRIPT_NAME + "_smart_filter")
msg = self._membership_message(user, leave_type)
self.print_date_tags(msg, date, tags + (extra_tags or []))
self.remove_smart_filtered_nick(user.nick)
if user.nick in self.users:
del self.users[user.nick]
def part(self, nick, date, message=True, extra_tags=None):
# type: (str, int, bool, Optional[List[str]]) -> None
self._leave(nick, date, message, "part", extra_tags)
def kick(self, nick, date, message=True, extra_tags=None):
# type: (str, int, bool, Optional[List[str]]) -> None
self._leave(nick, date, message, "kick", extra_tags)
def _print_topic(self, nick, topic, date):
user = self._get_user(nick)
tags = self._message_tags(user, "topic")
data = (
"{prefix}{nick} has changed "
"the topic for {chan_color}{room}{ncolor} "
'to "{topic}"'
).format(
prefix=W.prefix("network"),
nick=user.nick,
chan_color=W.color("chat_channel"),
ncolor=W.color("reset"),
room=self.short_name,
topic=topic,
)
self.print_date_tags(data, date, tags)
user.update_speaking_time(date)
self.unmask_smart_filtered_nick(nick)
@property
def topic(self):
return W.buffer_get_string(self._ptr, "title")
@topic.setter
def topic(self, topic):
W.buffer_set(self._ptr, "title", topic)
def change_topic(self, nick, topic, date, message=True):
if message:
self._print_topic(nick, topic, date)
self.topic = topic
self.topic_author = nick
self.topic_date = date
def self_message(self, nick, message, date, tags=None):
user = self._get_user(nick)
tags = self._message_tags(user, "self_message") + (tags or [])
self._print_message(user, message, date, tags)
def self_action(self, nick, message, date, tags=None):
user = self._get_user(nick)
tags = self._message_tags(user, "self_message") + (tags or [])
tags.append(SCRIPT_NAME + "_action")
self._print_action(user, message, date, tags)
@property
def type(self):
return W.buffer_get_string(self._ptr, "localvar_type")
@property
def short_name(self):
return W.buffer_get_string(self._ptr, "short_name")
@short_name.setter
def short_name(self, name):
W.buffer_set(self._ptr, "short_name", name)
@property
def name(self):
return W.buffer_get_string(self._ptr, "name")
@name.setter
def name(self, name):
W.buffer_set(self._ptr, "name", name)
@property
def number(self):
"""Get the buffer number, starts at 1."""
return int(W.buffer_get_integer(self._ptr, "number"))
@number.setter
def number(self, n):
W.buffer_set(self._ptr, "number", str(n))
def find_lines(self, predicate, max_lines=None):
lines = []
count = 0
for line in self.lines:
if predicate(line):
lines.append(line)
count += 1
if max_lines is not None and count == max_lines:
return lines
return lines
class RoomBuffer(object):
def __init__(self, room, server_name, homeserver, prev_batch):
self.room = room
self.homeserver = homeserver
self._backlog_pending = False
self.prev_batch = prev_batch
self.joined = True
self.leave_event_id = None # type: Optional[str]
self.members_fetched = False
self.first_view = True
self.first_backlog_request = True
self.unhandled_users = [] # type: List[str]
self.inactive_users = []
self.sent_messages_queue = dict() # type: Dict[UUID, OwnMessage]
self.printed_before_ack_queue = list() # type: List[UUID]
self.undecrypted_events = deque(maxlen=5000)
self.typing_notice_time = None
self._typing = False
self.typing_enabled = True
self.last_read_event = None
self._read_markers_enabled = True
self.server_name = server_name
self.last_message = None
buffer_name = "{}{}.{}".format(G.BUFFER_NAME_PREFIX, server_name, room.room_id)
# This dict remembers the connection from a user_id to the name we
# displayed in the buffer
self.displayed_nicks = {}
user = shorten_sender(self.room.own_user_id)
self.weechat_buffer = WeechatChannelBuffer(
buffer_name, server_name, user
)
W.buffer_set(
self.weechat_buffer._ptr,
"localvar_set_domain",
self.homeserver.hostname
)
W.buffer_set(
self.weechat_buffer._ptr,
"localvar_set_room_id",
room.room_id
)
if room.canonical_alias:
self.update_canonical_alias_localvar()
@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 G.CONFIG.look.encryption_warning_sign
@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 printed_event_ids(self):
for line in self.weechat_buffer.lines:
for tag in line.tags:
if tag.startswith("matrix_id"):
event_id = tag[10:]
yield event_id
@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 iteration.
# Because the search time get's increasingly longer we're
# going to stop adding inactive users, they will be lazily added if
# they become active.
if is_state and not force_add and user.power_level <= 0:
if (len(self.displayed_nicks) >=
G.CONFIG.network.max_nicklist_users):
self.inactive_users.append(user_id)
return
try:
self.inactive_users.remove(user_id)
except ValueError:
pass
short_name = shorten_sender(user.user_id)
# TODO handle this special case for discord bridge users and
# freenode bridge users better
if (user.user_id.startswith("@_discord_") or
user.user_id.startswith("@_slack_") or
user.user_id.startswith("@_discordpuppet_") or
user.user_id.startswith("@_slackpuppet_") or
user.user_id.startswith("@whatsapp_") or
user.user_id.startswith("@facebook_") or
user.user_id.startswith("@telegram_") or
user.user_id.startswith("@_telegram_") or
user.user_id.startswith("@_xmpp_") or
user.user_id.startswith("@irc_")):
if user.display_name:
short_name = user.display_name[0:50]
elif user.user_id.startswith("@twilio_"):
short_name = shorten_sender(user.user_id[7:])
elif user.user_id.startswith("@freenode_"):
short_name = shorten_sender(user.user_id[9:])
elif user.user_id.startswith("@_ircnet_"):
short_name = shorten_sender(user.user_id[8:])
elif user.user_id.startswith("@gitter_"):
short_name = shorten_sender(user.user_id[7:])
# TODO make this configurable
if not short_name or short_name in self.displayed_nicks.values():
# Use the full user id, but don't include the @
nick = user_id[1:]
else:
nick = short_name
buffer_user = RoomUser(nick, user_id, user.power_level, date)
self.displayed_nicks[user_id] = nick
if self.room.own_user_id == user_id:
buffer_user.color = "weechat.color.chat_nick_self"
user.nick_color = "weechat.color.chat_nick_self"
self.weechat_buffer.join(buffer_user, date, not is_state)
def handle_membership_events(self, event, is_state):
date = server_ts_to_weechat(event.server_timestamp)
if event.content["membership"] == "join":
if (event.state_key not in self.displayed_nicks
and event.state_key not in self.inactive_users):
if len(self.room.users) > 100:
self.unhandled_users.append(event.state_key)
return
self.add_user(event.state_key, date, is_state)
else:
# TODO print out profile changes
return
elif event.content["membership"] == "leave":
if event.state_key in self.unhandled_users:
self.unhandled_users.remove(event.state_key)
return
nick = self.find_nick(event.state_key)
if event.sender == event.state_key:
self.weechat_buffer.part(nick, date, not is_state)
else:
self.weechat_buffer.kick(nick, date, not is_state)
if event.state_key in self.displayed_nicks:
del self.displayed_nicks[event.state_key]
# We left the room, remember the event id of our leave, if we
# rejoin we get events that came before this event as well as
# after our leave, this way we know where to continue
if event.state_key == self.room.own_user_id:
self.leave_event_id = event.event_id
elif event.content["membership"] == "invite":
if is_state:
return
self.weechat_buffer.invite(event.state_key, date)
return
self.update_buffer_name()
def update_buffer_name(self):
if self.room.is_named:
if self.room.name and self.room.name != "#":
room_name = self.room.name
room_name = (room_name if room_name.startswith("#")
else "#" + room_name)
elif self.room.canonical_alias:
room_name = self.room.canonical_alias
self.update_canonical_alias_localvar()
elif self.room.name == "#":
room_name = "##"
else:
room_name = self.room.display_name
if room_name is None:
# Use placeholder room name
room_name = 'Empty room (?)'
self.weechat_buffer.short_name = room_name
if G.CONFIG.human_buffer_names:
buffer_name = "{}.{}".format(self.server_name, room_name)
self.weechat_buffer.name = buffer_name
def update_canonical_alias_localvar(self):
W.buffer_set(
self.weechat_buffer._ptr,
"localvar_set_canonical_alias",
self.room.canonical_alias
)
def _redact_line(self, event):
def predicate(event_id, line):
def already_redacted(tags):
if SCRIPT_NAME + "_redacted" in tags:
return True
return False
event_tag = SCRIPT_NAME + "_id_{}".format(event_id)
tags = line.tags
if event_tag in tags and not already_redacted(tags):
return True
return False
def redact_string(message):
new_message = ""
if G.CONFIG.look.redactions == RedactType.STRIKETHROUGH:
plaintext_msg = W.string_remove_color(message, "")
new_message = string_strikethrough(plaintext_msg)
elif G.CONFIG.look.redactions == RedactType.NOTICE:
new_message = message
elif G.CONFIG.look.redactions == RedactType.DELETE:
pass
return new_message
lines = self.weechat_buffer.find_lines(
partial(predicate, event.redacts)
)
# No line to redact, return early
if not lines:
return
censor = self.find_nick(event.sender)
redaction_msg = Render.redacted(censor, event.reason)
line = lines[0]
message = line.message
tags = line.tags
new_message = redact_string(message)
message = " ".join(s for s in [new_message, redaction_msg] if s)
tags.append("matrix_redacted")
line.message = message
line.tags = tags
for line in lines[1:]:
message = line.message
tags = line.tags
new_message = redact_string(message)
if not new_message:
new_message = redaction_msg
elif G.CONFIG.look.redactions == RedactType.NOTICE:
new_message += " {}".format(redaction_msg)
tags.append("matrix_redacted")
line.message = new_message
line.tags = tags
def _handle_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 print_room_message(self, event, extra_tags=None):
extra_tags = extra_tags or []
nick = self.find_nick(event.sender)
data = Render.message(event.body, event.formatted_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_tags,
extra_prefix
)
def print_room_emote(self, event, extra_tags=None):
extra_tags = extra_tags or []
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_tags,
extra_prefix
)
def print_room_notice(self, event, extra_tags=None):
extra_tags = extra_tags or []
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_tags,
extra_prefix
)
def print_room_media(self, event, extra_tags=None):
extra_tags = extra_tags or []
nick = self.find_nick(event.sender)
date = server_ts_to_weechat(event.server_timestamp)
if isinstance(event, RoomMessageMedia):
data = Render.media(event.url, event.body, self.homeserver.geturl())
else:
data = Render.encrypted_media(
event.url, event.body, event.key["k"], event.hashes["sha256"],
event.iv, self.homeserver.geturl()
)
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_tags,
extra_prefix
)
def print_unknown(self, event, extra_tags=None):
extra_tags = extra_tags or []
nick = self.find_nick(event.sender)
date = server_ts_to_weechat(event.server_timestamp)
data = Render.unknown(event.type, event.content)
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_tags,
extra_prefix
)
def print_redacted(self, event, extra_tags=None):
extra_tags = extra_tags or []
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")
tags += extra_tags
censor = self.find_nick(event.redacter)
data = Render.redacted(censor, event.reason)
self.weechat_buffer.message(nick, data, date, tags)
def print_room_encryption(self, event, extra_tags=None):
nick = self.find_nick(event.sender)
data = Render.room_encryption(nick)
# TODO this should also have tags
self.weechat_buffer.info(data)
def print_megolm(self, event, extra_tags=None):
extra_tags = extra_tags or []
nick = self.find_nick(event.sender)
date = server_ts_to_weechat(event.server_timestamp)
data = Render.megolm()
session_id_tag = SCRIPT_NAME + "_sessionid_" + event.session_id
self.weechat_buffer.message(
nick,
data,
date,
self.get_event_tags(event) + [session_id_tag] + extra_tags
)
self.undecrypted_events.append(event)
def print_bad_event(self, event, extra_tags=None):
extra_tags = extra_tags or []
nick = self.find_nick(event.sender)
date = server_ts_to_weechat(event.server_timestamp)
data = Render.bad(event)
extra_prefix = self.warning_prefix
self.weechat_buffer.message(
nick, data, date, self.get_event_tags(event) + extra_tags,
extra_prefix
)
def handle_room_messages(self, event, extra_tags=None):
if isinstance(event, RoomMessageEmote):
self.print_room_emote(event, extra_tags)
elif isinstance(event, RoomMessageText):
self.print_room_message(event, extra_tags)
elif isinstance(event, RoomMessageNotice):
self.print_room_notice(event, extra_tags)
elif isinstance(event, RoomMessageMedia):
self.print_room_media(event, extra_tags)
elif isinstance(event, RoomEncryptedMedia):
self.print_room_media(event, extra_tags)
elif isinstance(event, RoomMessageUnknown):
self.print_unknown(event, extra_tags)
elif isinstance(event, RoomEncryptionEvent):
self.print_room_encryption(event, extra_tags)
elif isinstance(event, MegolmEvent):
self.print_megolm(event, extra_tags)
def force_load_member(self, event):
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)
def handle_timeline_event(self, event, extra_tags=None):
# TODO this should be done for every messagetype that gets printed in
# the buffer
if isinstance(event, (RoomMessage, MegolmEvent)):
self.force_load_member(event)
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):
self.print_room_emote(event, extra_tags)
elif isinstance(event, RoomMessageText):
self.print_room_message(event, extra_tags)
elif isinstance(event, RoomMessageNotice):
self.print_room_notice(event, extra_tags)
elif isinstance(event, RoomMessageMedia):
self.print_room_media(event, extra_tags)
elif isinstance(event, RoomEncryptedMedia):
self.print_room_media(event, extra_tags)
elif isinstance(event, RoomMessageUnknown):
self.print_unknown(event, extra_tags)
elif isinstance(event, RedactionEvent):
self._redact_line(event)
elif isinstance(event, RedactedEvent):
self.print_redacted(event, extra_tags)
elif isinstance(event, RoomEncryptionEvent):
self.print_room_encryption(event, extra_tags)
elif isinstance(event, PowerLevelsEvent):
# TODO we should print out a message for this event
self._handle_power_level(event)
elif isinstance(event, MegolmEvent):
self.print_megolm(event, extra_tags)
elif isinstance(event, UnknownEvent):
pass
elif isinstance(event, BadEvent):
self.print_bad_event(event, extra_tags)
elif isinstance(event, UnknownBadEvent):
self.error("Unknown bad event: {}".format(
pprint.pformat(event.source)
))
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 an 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 single 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_message(self, event):
tags = list(self.weechat_buffer.tags["old_message"])
# TODO events that change the room state (topics, membership changes,
# etc...) should be printed out as well, but for this to work without
# messing up the room state the state change will need to be separated
# from the print logic.
if isinstance(event, RoomMessage):
self.force_load_member(event)
self.handle_room_messages(event, tags)
elif isinstance(event, MegolmEvent):
self.print_megolm(event, tags)
elif isinstance(event, RedactedEvent):
self.print_redacted(event, tags)
elif isinstance(event, BadEvent):
self.print_bad_event(event, 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:
# The first backlog request seems to have a race condition going on
# where we receive a message in a sync response, get a prev_batch,
# yet when we request older messages with the prev_batch the same
# message might appear in the room messages response. This only
# seems to happen if the message is relatively recently sent.
# Because of this we check if our first backlog request contains
# some already printed events, if so; skip printing them.
if (self.first_backlog_request
and event.event_id in self.printed_event_ids):
continue
self.old_message(event)
self.sort_messages()
self.first_backlog_request = False
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)