(teams) add matrix webhook for teams

A webhook to invite/kick team members to a matrix room.
This commit is contained in:
Marie PUPO JEAMMET
2025-05-09 17:49:20 +02:00
committed by Quentin BEY
parent 7bebf13d88
commit cc39ed5298
14 changed files with 743 additions and 56 deletions

View File

@@ -0,0 +1,136 @@
"""Matrix client for interoperability to synchronize with remote service providers."""
import logging
from django.conf import settings
import requests
from rest_framework.status import (
HTTP_200_OK,
)
from urllib3.util import Retry
logger = logging.getLogger(__name__)
adapter = requests.adapters.HTTPAdapter(
max_retries=Retry(
total=4,
backoff_factor=0.1,
status_forcelist=[500, 502],
allowed_methods=["POST"],
)
)
session = requests.Session()
session.mount("http://", adapter)
session.mount("https://", adapter)
class MatrixAPIClient:
"""A client to interact with Matrix API"""
secret = settings.TCHAP_ACCESS_TOKEN
def get_headers(self, webhook):
"""Build header dict from webhook object."""
headers = {"Content-Type": "application/json"}
token = webhook.secret if webhook.secret else None
if "tchap.gouv.fr" in webhook.url:
token = settings.TCHAP_ACCESS_TOKEN
headers["Authorization"] = f"Bearer {token}"
return headers
def _get_room_url(self, webhook_url):
"""Returns room id from webhook url."""
room_id = webhook_url.split("/room/")[1]
base_url = room_id.split(":")[1]
if "tchap.gouv.fr" in webhook_url:
base_url = f"matrix.{base_url}"
return f"https://{base_url}/_matrix/client/v3/rooms/{room_id}"
def get_user_id(self, user):
"""Returns user id from email."""
if user.email is None:
raise ValueError("You must first set an email for the user.")
return f"@{user.email.replace('@', ':')}"
def join_room(self, webhook):
"""Accept invitation to the room. As of today, it is a mandatory step
to make sure our account will be able to invite/remove users."""
if webhook.secret is None:
raise ValueError("Please configure this webhook's secret access token.")
return session.post(
f"{self._get_room_url(webhook.url)}/join",
json={},
headers=self.get_headers(webhook),
verify=False,
timeout=3,
)
def add_user_to_group(self, webhook, user):
"""Send request to invite an user to a room or space upon adding them to group.."""
join_response = self.join_room(webhook)
if join_response.status_code != HTTP_200_OK:
logger.error(
"Synchronization failed (cannot join room) %s",
webhook.url,
)
return join_response, False
user_id = self.get_user_id(user)
response = session.post(
f"{self._get_room_url(webhook.url)}/invite",
json={
"user_id": user_id,
"reason": f"User added to team {webhook.team} on People",
},
headers=self.get_headers(webhook),
verify=False,
timeout=3,
)
# Checks for false negative
# (i.e. trying to invite user already in room)
webhook_succeeded = False
if (
response.status_code == HTTP_200_OK
or b"is already in the room." in response.content
):
webhook_succeeded = True
return response, webhook_succeeded
def remove_user_from_group(self, webhook, user):
"""Send request to kick an user from a room or space upon removing them from group."""
join_response = self.join_room(webhook)
if join_response.status_code != HTTP_200_OK:
logger.error(
"Synchronization failed (cannot join room) %s",
webhook.url,
)
return join_response, False
user_id = self.get_user_id(user)
response = session.post(
f"{self._get_room_url(webhook.url)}/kick",
json={
"user_id": user_id,
"reason": f"User removed from team {webhook.team} on People",
},
headers=self.get_headers(webhook),
verify=False,
timeout=3,
)
# Checks for false negative
# (i.e. trying to remove user who already left the room)
webhook_succeeded = False
if (
response.status_code == HTTP_200_OK
or b"The target user is not in the room" in response.content
):
webhook_succeeded = True
return response, webhook_succeeded

View File

@@ -39,7 +39,7 @@ class SCIMClient:
],
}
return session.patch(
response = session.patch(
webhook.url,
json=payload,
headers=webhook.get_headers(),
@@ -47,6 +47,8 @@ class SCIMClient:
timeout=3,
)
return response, response.ok
def remove_user_from_group(self, webhook, user):
"""Remove a user from a group by its ID or email."""
payload = {
@@ -61,10 +63,12 @@ class SCIMClient:
}
],
}
return session.patch(
response = session.patch(
webhook.url,
json=payload,
headers=webhook.get_headers(),
verify=False,
timeout=3,
)
return response, response.ok

View File

@@ -4,14 +4,16 @@ import logging
import requests
from core import enums
from core.enums import WebhookStatusChoices
from .matrix import MatrixAPIClient
from .scim import SCIMClient
logger = logging.getLogger(__name__)
class WebhookSCIMClient:
class WebhookClient:
"""Wraps the SCIM client to record call results on webhooks."""
def __getattr__(self, name):
@@ -26,31 +28,15 @@ class WebhookSCIMClient:
if not webhook.url:
continue
client = SCIMClient()
status = WebhookStatusChoices.FAILURE
try:
response = getattr(client, name)(webhook, user)
response, webhook_succeeded = self._get_response_and_status(
name, webhook, user
)
except requests.exceptions.RetryError as exc:
logger.error(
"%s synchronization failed due to max retries exceeded with url %s",
name,
webhook.url,
exc_info=exc,
)
except requests.exceptions.RequestException as exc:
logger.error(
"%s synchronization failed with %s.",
name,
webhook.url,
exc_info=exc,
)
else:
extra = {
"response": response.content,
}
if response is not None:
extra = {"response": response.content}
# pylint: disable=no-member
if response.status_code == requests.codes.ok:
if webhook_succeeded:
logger.info(
"%s synchronization succeeded with %s",
name,
@@ -71,5 +57,37 @@ class WebhookSCIMClient:
return wrapper
def _get_client(self, webhook):
"""Get client depending on the protocol."""
if webhook.protocol == enums.WebhookProtocolChoices.MATRIX:
return MatrixAPIClient()
scim_synchronizer = WebhookSCIMClient()
return SCIMClient()
def _get_response_and_status(self, name, webhook, user):
"""Get response from webhook outside party."""
client = self._get_client(webhook)
try:
response, webhook_succeeded = getattr(client, name)(webhook, user)
except requests.exceptions.RetryError as exc:
logger.error(
"%s synchronization failed due to max retries exceeded with url %s",
name,
webhook.url,
exc_info=exc,
)
except requests.exceptions.RequestException as exc:
logger.error(
"%s synchronization failed with %s.",
name,
webhook.url,
exc_info=exc,
)
else:
return response, webhook_succeeded
return None, False
webhooks_synchronizer = WebhookClient()