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