From e76bde5aa8e5db6929724bdd9d62bab7358c0fcc Mon Sep 17 00:00:00 2001 From: Jonathan Weth <git@jonathanweth.de> Date: Tue, 28 Dec 2021 16:19:49 +0100 Subject: [PATCH] Add first attempts for syncing groups to rooms --- aleksis/apps/matrix/matrix.py | 19 ++++ .../apps/matrix/migrations/0001_initial.py | 71 ++++++++++++ aleksis/apps/matrix/migrations/__init__.py | 0 aleksis/apps/matrix/models.py | 61 +++++++++- aleksis/apps/matrix/preferences.py | 35 +++++- aleksis/apps/matrix/tests/test_matrix.py | 107 ++++++++++++++++++ conftest.py | 55 +++++++++ pyproject.toml | 3 + 8 files changed, 347 insertions(+), 4 deletions(-) create mode 100644 aleksis/apps/matrix/matrix.py create mode 100644 aleksis/apps/matrix/migrations/0001_initial.py create mode 100644 aleksis/apps/matrix/migrations/__init__.py create mode 100644 aleksis/apps/matrix/tests/test_matrix.py create mode 100644 conftest.py diff --git a/aleksis/apps/matrix/matrix.py b/aleksis/apps/matrix/matrix.py new file mode 100644 index 0000000..5184286 --- /dev/null +++ b/aleksis/apps/matrix/matrix.py @@ -0,0 +1,19 @@ +from urllib.parse import urljoin + +from aleksis.core.util.core_helpers import get_site_preferences + + +class MatrixException(Exception): + pass + + +def build_url(path): + return urljoin( + urljoin(get_site_preferences()["matrix__homeserver"], "_matrix/client/v3/"), path + ) + + +def get_headers(): + return { + "Authorization": "Bearer " + get_site_preferences()["matrix__access_token"], + } diff --git a/aleksis/apps/matrix/migrations/0001_initial.py b/aleksis/apps/matrix/migrations/0001_initial.py new file mode 100644 index 0000000..60d62d5 --- /dev/null +++ b/aleksis/apps/matrix/migrations/0001_initial.py @@ -0,0 +1,71 @@ +# Generated by Django 3.2.10 on 2021-12-27 23:08 + +import aleksis.core.managers +import django.contrib.sites.managers +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('core', '0031_auto_20211228_0008'), + ('sites', '0002_alter_domain_unique'), + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='MatrixRoom', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extended_data', models.JSONField(default=dict, editable=False)), + ('room_id', models.CharField(max_length=255, unique=True, verbose_name='Room ID')), + ('alias', models.CharField(blank=True, max_length=255, unique=True, verbose_name='Alias')), + ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matrix_spaces', to='core.group', verbose_name='Group')), + ('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_matrix.matrixroom_set+', to='contenttypes.contenttype')), + ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')), + ], + options={ + 'verbose_name': 'Matrix room', + 'verbose_name_plural': 'Matrix rooms', + }, + managers=[ + ('objects', aleksis.core.managers.PolymorphicCurrentSiteManager()), + ], + ), + migrations.CreateModel( + name='MatrixProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extended_data', models.JSONField(default=dict, editable=False)), + ('matrix_id', models.CharField(max_length=255, unique=True, verbose_name='Matrix ID')), + ('person', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matrix_profile', to='core.person', verbose_name='Person')), + ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')), + ], + options={ + 'verbose_name': 'Matrix profile', + 'verbose_name_plural': 'Matrix profiles', + }, + managers=[ + ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ], + ), + migrations.CreateModel( + name='MatrixSpace', + fields=[ + ('matrixroom_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='matrix.matrixroom')), + ('children', models.ManyToManyField(blank=True, related_name='parents', to='matrix.MatrixRoom', verbose_name='Child rooms/spaces')), + ], + options={ + 'verbose_name': 'Matrix space', + 'verbose_name_plural': 'Matrix spaces', + }, + bases=('matrix.matrixroom',), + managers=[ + ('objects', aleksis.core.managers.PolymorphicCurrentSiteManager()), + ], + ), + ] diff --git a/aleksis/apps/matrix/migrations/__init__.py b/aleksis/apps/matrix/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aleksis/apps/matrix/models.py b/aleksis/apps/matrix/models.py index 3b3dd68..c385e71 100644 --- a/aleksis/apps/matrix/models.py +++ b/aleksis/apps/matrix/models.py @@ -1,6 +1,11 @@ +import re + from django.db import models +from django.template.defaultfilters import slugify from django.utils.translation import gettext_lazy as _ +import requests + from aleksis.core.mixins import ExtensibleModel, ExtensiblePolymorphicModel from aleksis.core.models import Group, Person @@ -8,7 +13,7 @@ from aleksis.core.models import Group, Person class MatrixProfile(ExtensibleModel): """Model for a Matrix profile.""" - matrix_id = models.CharField(verbose_name=_("Matrix ID"), unique=True) + matrix_id = models.CharField(max_length=255, verbose_name=_("Matrix ID"), unique=True) person = models.OneToOneField( Person, on_delete=models.CASCADE, @@ -26,8 +31,8 @@ class MatrixProfile(ExtensibleModel): class MatrixRoom(ExtensiblePolymorphicModel): """Model for a Matrix room.""" - room_id = models.CharField(verbose_name=_("Room ID"), unique=True) - alias = models.CharField(verbose_name=_("Alias"), unique=True, blank=True, null=True) + room_id = models.CharField(max_length=255, verbose_name=_("Room ID"), unique=True) + alias = models.CharField(max_length=255, verbose_name=_("Alias"), unique=True, blank=True) group = models.ForeignKey( Group, on_delete=models.CASCADE, @@ -37,6 +42,56 @@ class MatrixRoom(ExtensiblePolymorphicModel): related_name="matrix_spaces", ) + @classmethod + def from_group(self, group: Group): + """Create a Matrix room from a group.""" + from .matrix import MatrixException, build_url, get_headers + + try: + room = MatrixRoom.objects.get(group=group) + except MatrixRoom.DoesNotExist: + room = MatrixRoom(group=group) + + if room.room_id: + # Existing room, check if still accessible + r = requests.get( + build_url(f"directory/list/room/{room.room_id}"), headers=get_headers() + ) + if not r.status_code == requests.codes.ok: + raise MatrixException() + else: + # Room does not exist, create it + alias = slugify(group.short_name or group.name) + r = self._create_group(group.name, alias) + while r.json().get("errcode") == "M_ROOM_IN_USE": + match = re.match(r"^(.*)-(\d+)$", alias) + if match: + # Counter found, increase + prefix = match.group(1) + counter = int(match.group(2)) + 1 + alias = f"{prefix}-{counter}" + else: + # Counter not found, add one + alias = f"{alias}-2" + r = self._create_group(group.name, alias) + + if r.status_code == requests.codes.ok: + room.room_id = r.json()["room_id"] + room.alias = r.json()["room_alias"] + room.save() + else: + raise MatrixException(r.text) + return room + + @classmethod + def _create_group(self, name, alias): + from .matrix import build_url, get_headers + + body = {"preset": "private_chat", "name": name, "room_alias_name": alias} + r = requests.post(build_url("createRoom"), headers=get_headers(), json=body) + + return r + class Meta: verbose_name = _("Matrix room") verbose_name_plural = _("Matrix rooms") diff --git a/aleksis/apps/matrix/preferences.py b/aleksis/apps/matrix/preferences.py index b58b988..5b697bc 100644 --- a/aleksis/apps/matrix/preferences.py +++ b/aleksis/apps/matrix/preferences.py @@ -1,7 +1,7 @@ from django.utils.translation import gettext as _ from dynamic_preferences.preferences import Section -from dynamic_preferences.types import StringPreference +from dynamic_preferences.types import BooleanPreference, StringPreference from aleksis.core.registries import site_preferences_registry @@ -22,3 +22,36 @@ class AccessToken(StringPreference): name = "access_token" verbose_name = _("Access token to access homeserver") default = "" + + +@site_preferences_registry.register +class User(StringPreference): + section = matrix + name = "user" + verbose_name = _("User to access homeserver") + default = "" + + +@site_preferences_registry.register +class DeviceID(StringPreference): + section = matrix + name = "device_id" + verbose_name = _("Device ID") + default = "" + field_kwargs = {"editable": False} + + +@site_preferences_registry.register +class DeviceName(StringPreference): + section = matrix + name = "device_name" + verbose_name = _("Device name") + default = "AlekSIS" + + +@site_preferences_registry.register +class DisambiguateRoomAliases(BooleanPreference): + section = matrix + name = "disambiguate_room_aliases" + verbose_name = _("Disambiguate room aliases") + default = True diff --git a/aleksis/apps/matrix/tests/test_matrix.py b/aleksis/apps/matrix/tests/test_matrix.py new file mode 100644 index 0000000..23d5e08 --- /dev/null +++ b/aleksis/apps/matrix/tests/test_matrix.py @@ -0,0 +1,107 @@ +from datetime import date + +import pytest +import requests + +from aleksis.apps.matrix.models import MatrixRoom +from aleksis.core.models import Group, SchoolTerm +from aleksis.core.util.core_helpers import get_site_preferences + +pytestmark = pytest.mark.django_db + +SERVER_URL = "http://127.0.0.1:8008" + + +def test_connection(synapse): + + assert synapse["listeners"][0]["port"] == 8008 + + assert requests.get(SERVER_URL).status_code == requests.codes.ok + + +@pytest.fixture +def matrix_bot_user(synapse): + + from aleksis.apps.matrix.matrix import build_url + + body = {"username": "aleksis-bot", "password": "test", "auth": {"type": "m.login.dummy"}} + + get_site_preferences()["matrix__homeserver"] = SERVER_URL + + r = requests.post(build_url("register"), json=body) + print(r.text, build_url("register")) + assert r.status_code == requests.codes.ok + + user = r.json() + + get_site_preferences()["matrix__user"] = user["user_id"] + get_site_preferences()["matrix__device_id"] = user["device_id"] + get_site_preferences()["matrix__access_token"] = user["access_token"] + + yield r.json() + + +def test_create_room_for_group(matrix_bot_user): + from aleksis.apps.matrix.matrix import build_url, get_headers + + g = Group.objects.create(name="Test Room") + assert not MatrixRoom.objects.all().exists() + room = MatrixRoom.from_group(g) + + assert ":matrix.aleksis.example.org" in room.room_id + assert room.alias == "#test-room:matrix.aleksis.example.org" + + # On second get, it should be the same matrix room + assert MatrixRoom.from_group(g) == room + + r = requests.get(build_url(f"rooms/{room.room_id}/aliases"), headers=get_headers()) + aliases = r.json()["aliases"] + assert "#test-room:matrix.aleksis.example.org" in aliases + + +# +def test_create_room_for_group_short_name(matrix_bot_user): + + g = Group.objects.create(name="Test Room", short_name="test") + assert not MatrixRoom.objects.all().exists() + room = MatrixRoom.from_group(g) + assert room.alias == "#test:matrix.aleksis.example.org" + + +def test_room_alias_collision_same_name(matrix_bot_user): + from aleksis.apps.matrix.matrix import MatrixException + + g1 = Group.objects.create(name="Test Room") + g2 = Group.objects.create(name="test-room") + g3 = Group.objects.create(name="Test-Room") + g4 = Group.objects.create(name="test room") + room = MatrixRoom.from_group(g1) + assert room.alias == "#test-room:matrix.aleksis.example.org" + + room = MatrixRoom.from_group(g2) + assert room.alias == "#test-room-2:matrix.aleksis.example.org" + + room = MatrixRoom.from_group(g3) + assert room.alias == "#test-room-3:matrix.aleksis.example.org" + + get_site_preferences()["matrix__disambiguate_room_aliases"] = False + + with pytest.raises(MatrixException): + MatrixRoom.from_group(g4) + + +def test_room_alias_collision_school_term(matrix_bot_user): + school_term_a = SchoolTerm.objects.create( + name="Test Term A", date_start=date(2020, 1, 1), date_end=date(2020, 12, 31) + ) + school_term_b = SchoolTerm.objects.create( + name="Test Term B", date_start=date(2021, 1, 1), date_end=date(2021, 12, 31) + ) + g1 = Group.objects.create(name="Test Room", school_term=school_term_a) + g2 = Group.objects.create(name="Test Room", school_term=school_term_b) + + room = MatrixRoom.from_group(g1) + assert room.alias == "#test-room:matrix.aleksis.example.org" + + room = MatrixRoom.from_group(g2) + assert room.alias == "#test-room-2:matrix.aleksis.example.org" diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..eadfed8 --- /dev/null +++ b/conftest.py @@ -0,0 +1,55 @@ +import os + +import pytest +import yaml +from xprocess import ProcessStarter + + +@pytest.fixture +def synapse(xprocess, tmp_path): + path = os.path.dirname(__file__) + new_config_filename = os.path.join(tmp_path, "homeserver.yaml") + + files_to_replace = [ + "homeserver.yaml", + "matrix.aleksis.example.org.log.config", + ] + for filename in files_to_replace: + new_filename = os.path.join(tmp_path, filename) + + with open(os.path.join(path, "synapse", filename), "r") as read_file: + content = read_file.read() + + content = content.replace("%path%", path) + content = content.replace("%tmp_path%", str(tmp_path)) + + with open(new_filename, "w") as write_file: + write_file.write(content) + + with open(new_config_filename, "r") as f: + config = yaml.safe_load(f) + + config["server_url"] = "http://127.0.0.1:8008" + + class SynapseStarter(ProcessStarter): + # startup pattern + pattern = "SynapseSite starting on 8008" + + # command to start process + args = [ + "python", + "-m", + "synapse.app.homeserver", + "--enable-registration", + "-c", + new_config_filename, + ] + + max_read_lines = 400 + timeout = 10 + + xprocess.ensure("synapse", SynapseStarter) + + yield config + + xprocess.getinfo("synapse").terminate() diff --git a/pyproject.toml b/pyproject.toml index ef782b7..e2222a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,8 @@ aleksis-core = "^2.1.dev0" [tool.poetry.dev-dependencies] aleksis-builddeps = "*" +matrix-synapse = "^1.49.2" +pytest-xprocess = "^0.18.1" [tool.poetry.plugins."aleksis.app"] matrix = "aleksis.apps.matrix.apps:DefaultConfig" @@ -47,3 +49,4 @@ exclude = "/migrations/" [build-system] requires = ["poetry>=1.0"] build-backend = "poetry.masonry.api" + -- GitLab