Feat: first working version :)
This commit is contained in:
parent
77af9a9428
commit
fdc2fb9b30
5 changed files with 294 additions and 58 deletions
|
@ -1,3 +1,8 @@
|
||||||
# matrix-bot
|
# matrix-bot
|
||||||
|
|
||||||
Unser Matrix-Bot, der Gruppen, Gruppenmitgliedschaften und Rechte verwaltet.
|
Unser Matrix-Bot, der Gruppen, Gruppenmitgliedschaften und Rechte verwaltet.
|
||||||
|
|
||||||
|
# How it works
|
||||||
|
|
||||||
|
1. load all users from matrix
|
||||||
|
2. add all users to the default channels
|
|
@ -10,4 +10,8 @@ export MATRIX_BOT_MATRIX_SPACE_ID="!bajjed:matrix.org"
|
||||||
export MATRIX_BOT_MATRIX_USERNAME="synapse"
|
export MATRIX_BOT_MATRIX_USERNAME="synapse"
|
||||||
export MATRIX_BOT_MATRIX_PASSWORD="SomeSuperSafePassword"
|
export MATRIX_BOT_MATRIX_PASSWORD="SomeSuperSafePassword"
|
||||||
|
|
||||||
|
export MATRIX_BOT_DEFAULT_ROOMS="info,talk,show"
|
||||||
|
export MATRIX_BOT_ADMIN_GROUP="p_admin"
|
||||||
|
export MATRIX_BOT_MODERATOR_GROUP="p_moderator"
|
||||||
|
|
||||||
python3 src/main.py
|
python3 src/main.py
|
|
@ -9,6 +9,9 @@ class LdapHelper:
|
||||||
self._bindPassword = ldapBindPassword
|
self._bindPassword = ldapBindPassword
|
||||||
self._baseDn = ldapBaseDn
|
self._baseDn = ldapBaseDn
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
self.unbind()
|
||||||
|
|
||||||
def bind(self):
|
def bind(self):
|
||||||
try:
|
try:
|
||||||
self._ldapConnection = ldap.initialize(f"{self._uri}")
|
self._ldapConnection = ldap.initialize(f"{self._uri}")
|
||||||
|
|
215
src/main.py
215
src/main.py
|
@ -1,41 +1,196 @@
|
||||||
import sys, coloredlogs, logging, os, time, asyncio
|
from email.policy import default
|
||||||
|
import sys
|
||||||
|
import coloredlogs
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
from tokenize import group
|
||||||
from urllib import response
|
from urllib import response
|
||||||
from ldapHelper import LdapHelper
|
from ldapHelper import LdapHelper
|
||||||
from matrixHelper import MatrixHelper
|
from matrixHelper import MatrixHelper
|
||||||
|
|
||||||
coloredlogs.install(level='INFO', fmt='%(asctime)s - [%(levelname)s] %(message)s')
|
coloredlogs.install(
|
||||||
|
level='INFO', fmt='%(asctime)s - [%(levelname)s] %(message)s')
|
||||||
|
|
||||||
|
|
||||||
class MlmMatrixBot:
|
class MlmMatrixBot:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._config = self._readConfig()
|
self._config = self._readConfig()
|
||||||
|
self._matrixUsername = f"@{self._config['MATRIX_USERNAME']}:{self._config['MATRIX_DOMAIN']}"
|
||||||
|
|
||||||
self._ldapHelper = LdapHelper(self._config['LDAP_URI'], self._config['LDAP_BIND_DN'], self._config['LDAP_BIND_DN_PASSWORD'], self._config['LDAP_BASE_DN'])
|
self._ldapHelper = LdapHelper(self._config['LDAP_URI'], self._config['LDAP_BIND_DN'],
|
||||||
self._matrixHelper = MatrixHelper(self._config['MATRIX_SERVER'], f"@{self._config['MATRIX_USERNAME']}:{self._config['MATRIX_DOMAIN']}", self._config['MATRIX_SPACE_ID'])
|
self._config['LDAP_BIND_DN_PASSWORD'], self._config['LDAP_BASE_DN'])
|
||||||
|
self._matrixHelper = MatrixHelper(
|
||||||
|
self._config['MATRIX_SERVER'], self._matrixUsername, self._config['MATRIX_SPACE_ID'], self._matrixUsername)
|
||||||
|
|
||||||
def __del__(self):
|
async def init(self):
|
||||||
self._ldapHelper.unbind()
|
|
||||||
asyncio.run(self._matrixHelper.logout())
|
|
||||||
|
|
||||||
async def run(self):
|
|
||||||
if not self._ldapHelper.bind():
|
if not self._ldapHelper.bind():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
print(self._ldapHelper.search("sophomorixType=project"))
|
|
||||||
|
|
||||||
if not await self._matrixHelper.login(self._config['MATRIX_PASSWORD']):
|
if not await self._matrixHelper.login(self._config['MATRIX_PASSWORD']):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
room = await self._matrixHelper.createRoom("test11", "test11")
|
async def run(self):
|
||||||
response = await self._matrixHelper._client.joined_rooms()
|
|
||||||
print(response.rooms)
|
|
||||||
|
|
||||||
print(await self._matrixHelper._client.room_invite(room.room_id, "@dozedler:makerchat.net"))
|
logging.info("==> Sync started <==")
|
||||||
|
|
||||||
await self._matrixHelper._client.sync_forever()
|
await self._create_default_rooms()
|
||||||
|
|
||||||
pass
|
current_users, all_projects = await self._load_users_and_groups()
|
||||||
|
|
||||||
|
await self._create_project_rooms(all_projects)
|
||||||
|
|
||||||
|
|
||||||
|
logging.info("= (4/5) Loading current rooms =")
|
||||||
|
matrix_rooms = await self._get_managed_rooms(with_power_levels=True)
|
||||||
|
matrix_room_ids = list(map(lambda room: room['id'], matrix_rooms))
|
||||||
|
room_name_id_map = {}
|
||||||
|
room_id_map = {
|
||||||
|
self._config['MATRIX_SPACE_ID']: {
|
||||||
|
'name': "default space",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for room in matrix_rooms:
|
||||||
|
room_name_id_map[room['name']] = room['id']
|
||||||
|
room_id_map[room['id']] = room
|
||||||
|
|
||||||
|
logging.info("= (5/5) Syncing user memberships =")
|
||||||
|
for user in current_users:
|
||||||
|
if not "mlm" in user['groups']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# default groups and space
|
||||||
|
rooms_to_join = self._get_default_rooms() + [self._config["MATRIX_SPACE_ID"]]
|
||||||
|
# projects
|
||||||
|
rooms_to_join += list(map(lambda group: group.replace('p_', ''), filter(lambda project: project.startswith("p_"), user['groups'])))
|
||||||
|
# resolve names to ids
|
||||||
|
rooms_to_join = list(map(
|
||||||
|
lambda room: room if room.startswith("!") else room_name_id_map[room],
|
||||||
|
rooms_to_join))
|
||||||
|
|
||||||
|
logging.info(f" \t* Syncing user {user['username']}")
|
||||||
|
for room_id in rooms_to_join:
|
||||||
|
# set power level
|
||||||
|
if room_id in user['rooms']:
|
||||||
|
if ( user['matrix_username'] in room_id_map[room_id]['power_levels'] and room_id_map[room_id]['power_levels'][user['matrix_username']] != user['power_level']) or (user['matrix_username'] not in room_id_map[room_id]['power_levels'] and user['power_level'] != 0):
|
||||||
|
logging.info(f"\t\t* Setting power level in {room_id_map[room_id]['name']} to {user['power_level']}")
|
||||||
|
await self._matrixHelper.set_user_power_level_in_room(user['matrix_username'], room_id, user['power_level'])
|
||||||
|
continue
|
||||||
|
|
||||||
|
logging.info(f"\t* joining {room_id_map[room_id]['name']}")
|
||||||
|
await self._matrixHelper.add_user_to_room(user['matrix_username'], room_id)
|
||||||
|
if user['power_level'] != 0:
|
||||||
|
logging.info(f"\t\t* Setting power level in {room_id_map[room_id]['name']} to {user['power_level']}")
|
||||||
|
await self._matrixHelper.set_user_power_level_in_room(user['matrix_username'], room_id, user['power_level'])
|
||||||
|
|
||||||
|
|
||||||
|
# remove from rooms that are not in the groups
|
||||||
|
rooms_to_be_left = list(filter(lambda room: room in matrix_room_ids and not room in rooms_to_join, user['rooms']))
|
||||||
|
for room_id in rooms_to_be_left:
|
||||||
|
logging.info(f"\t* leaving {room_id_map[room_id]['name']}")
|
||||||
|
await self._matrixHelper.remove_user_from_room(
|
||||||
|
user['matrix_username'], room_id)
|
||||||
|
|
||||||
|
|
||||||
|
logging.info("==> Sync finished <==")
|
||||||
|
|
||||||
|
async def _create_default_rooms(self):
|
||||||
|
# create default rooms
|
||||||
|
logging.info("= (1/5) Creating default rooms =")
|
||||||
|
await self._create_rooms(self._get_default_rooms())
|
||||||
|
|
||||||
|
async def _load_users_and_groups(self):
|
||||||
|
"""
|
||||||
|
loads users and their groups from ldap and matrix and merges them
|
||||||
|
returns:
|
||||||
|
([
|
||||||
|
{
|
||||||
|
'username': 'dozedler',
|
||||||
|
'groups': ['mlm', 'p_3d-printing'],
|
||||||
|
'rooms': ['!room1:matrix.org', '!room2:matrix.org'],
|
||||||
|
'matrix_username': '@dozedler:matrix.org',
|
||||||
|
'is_admin': True
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'mlm',
|
||||||
|
'p_3d-printing'
|
||||||
|
])
|
||||||
|
"""
|
||||||
|
logging.info("= (2/5) Loading users and their groups =")
|
||||||
|
matrix_users = await self._matrixHelper.get_users()
|
||||||
|
current_users = []
|
||||||
|
all_projects = []
|
||||||
|
|
||||||
|
for user in matrix_users:
|
||||||
|
username = user['name'].split(':')[0].replace('@', '')
|
||||||
|
rc, ldap_user = self._ldapHelper.search(
|
||||||
|
f"(sAMAccountName={username})", ['distinguishedName'])
|
||||||
|
if not rc:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rc, raw_groups = self._ldapHelper.search(f"(&(member:1.2.840.113556.1.4.1941:={ldap_user[0]['distinguishedName']})(|(sophomorixType=adminclass)(sophomorixType=project)))", [
|
||||||
|
'sAMAccountName', 'sophomorixType', 'sophomorixMailList'])
|
||||||
|
|
||||||
|
if not rc:
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_groups = list(map(lambda group: group['sAMAccountName'], raw_groups))
|
||||||
|
power_level = 100 if self._config['ADMIN_GROUP'] in all_groups else 50 if self._config['MODERATOR_GROUP'] in all_groups else 0
|
||||||
|
|
||||||
|
groups = list(filter(lambda group: group['sophomorixMailList'] == 'TRUE' or group['sophomorixType'] == "adminclass", raw_groups))
|
||||||
|
|
||||||
|
all_projects.extend(list(map(lambda group: group['sAMAccountName'], filter(
|
||||||
|
lambda group: 'sophomorixType' in group and group['sophomorixType'] == 'project', groups))))
|
||||||
|
|
||||||
|
rooms = await self._matrixHelper.get_rooms_of_user(user['name'])
|
||||||
|
groups = list(map(lambda group: group['sAMAccountName'], groups))
|
||||||
|
|
||||||
|
current_users.append({
|
||||||
|
'username': username,
|
||||||
|
'matrix_username': user['name'],
|
||||||
|
'groups': groups,
|
||||||
|
'rooms': rooms,
|
||||||
|
'power_level': power_level
|
||||||
|
})
|
||||||
|
|
||||||
|
all_projects = list(set(all_projects))
|
||||||
|
|
||||||
|
return current_users, all_projects
|
||||||
|
|
||||||
|
async def _create_project_rooms(self, projects):
|
||||||
|
logging.info("= (3/5) Creating project rooms =")
|
||||||
|
projects = list(map(lambda project: project.replace('p_', ''), projects))
|
||||||
|
await self._create_rooms(projects)
|
||||||
|
|
||||||
|
async def _create_rooms(self, rooms):
|
||||||
|
matrix_room_names = list(map(lambda room: room['name'], await self._get_managed_rooms()))
|
||||||
|
for room in rooms:
|
||||||
|
if room not in matrix_room_names:
|
||||||
|
logging.info(f"* Creating room {room}")
|
||||||
|
await self._matrixHelper.create_room(room, room)
|
||||||
|
|
||||||
|
async def _get_managed_rooms(self, with_power_levels=False):
|
||||||
|
matrix_rooms = await self._matrixHelper.get_rooms()
|
||||||
|
matrix_rooms = filter(
|
||||||
|
lambda room: room['name'] != None and room['creator'] == self._matrixUsername, matrix_rooms)
|
||||||
|
matrix_rooms = map(
|
||||||
|
lambda room: {'id': room['room_id'], 'name': room['name']}, matrix_rooms)
|
||||||
|
matrix_rooms = list(matrix_rooms)
|
||||||
|
|
||||||
|
if with_power_levels:
|
||||||
|
for i in range(len(matrix_rooms)):
|
||||||
|
logging.info(f"\t* Getting power levels of room {matrix_rooms[i]['name']}")
|
||||||
|
matrix_rooms[i]['power_levels'] = await self._matrixHelper.get_room_power_levels(matrix_rooms[i]['id'])
|
||||||
|
|
||||||
|
return matrix_rooms
|
||||||
|
|
||||||
|
def _get_default_rooms(self):
|
||||||
|
return self._config['DEFAULT_ROOMS'].split(',')
|
||||||
|
|
||||||
def _readConfig(self):
|
def _readConfig(self):
|
||||||
requiredConfigKeys = [
|
requiredConfigKeys = [
|
||||||
|
@ -48,6 +203,9 @@ class MlmMatrixBot:
|
||||||
'MATRIX_BOT_MATRIX_SERVER',
|
'MATRIX_BOT_MATRIX_SERVER',
|
||||||
'MATRIX_BOT_MATRIX_USERNAME',
|
'MATRIX_BOT_MATRIX_USERNAME',
|
||||||
'MATRIX_BOT_MATRIX_PASSWORD',
|
'MATRIX_BOT_MATRIX_PASSWORD',
|
||||||
|
'MATRIX_BOT_DEFAULT_ROOMS',
|
||||||
|
'MATRIX_BOT_ADMIN_GROUP',
|
||||||
|
'MATRIX_BOT_MODERATOR_GROUP'
|
||||||
]
|
]
|
||||||
|
|
||||||
allowedConfigKeys = [
|
allowedConfigKeys = [
|
||||||
|
@ -58,12 +216,15 @@ class MlmMatrixBot:
|
||||||
|
|
||||||
for configKey in requiredConfigKeys:
|
for configKey in requiredConfigKeys:
|
||||||
if configKey not in os.environ:
|
if configKey not in os.environ:
|
||||||
sys.exit (f"Required environment value {configKey} is not set")
|
logging.error(f"Required environment value {configKey} is not set")
|
||||||
config[configKey.replace('MATRIX_BOT_', '')] = os.environ[configKey]
|
sys.exit()
|
||||||
|
config[configKey.replace('MATRIX_BOT_', '')
|
||||||
|
] = os.environ[configKey]
|
||||||
|
|
||||||
for configKey in allowedConfigKeys:
|
for configKey in allowedConfigKeys:
|
||||||
if configKey in os.environ:
|
if configKey in os.environ:
|
||||||
config[configKey.replace('MATRIX_BOT_', '')] = os.environ[configKey]
|
config[configKey.replace(
|
||||||
|
'MATRIX_BOT_', '')] = os.environ[configKey]
|
||||||
|
|
||||||
logging.info("CONFIG:")
|
logging.info("CONFIG:")
|
||||||
for key, value in config.items():
|
for key, value in config.items():
|
||||||
|
@ -71,9 +232,17 @@ class MlmMatrixBot:
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
|
async def main():
|
||||||
bot = MlmMatrixBot()
|
bot = MlmMatrixBot()
|
||||||
|
await bot.init()
|
||||||
|
while True:
|
||||||
|
await bot.run()
|
||||||
|
time.sleep(60)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
asyncio.run(bot.run())
|
asyncio.run(main())
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logging.info("Bye")
|
logging.info("Bye")
|
|
@ -1,40 +1,35 @@
|
||||||
|
from asyncio import current_task, protocols
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Dict, Any, Optional, Sequence
|
from typing import Dict, Any, Optional, Sequence
|
||||||
import logging
|
import logging
|
||||||
import nio
|
import nio
|
||||||
|
import synapse_admin
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
class Schemas:
|
class MatrixAdminClient:
|
||||||
room_state = {
|
def __init__(self, server: str, access_token: str) -> None:
|
||||||
"type": "object",
|
protocol = server.split("://")[0] + "://"
|
||||||
"properties": {"event_id": {"type": "string"}},
|
server = server.split("://")[1]
|
||||||
"required": ["event_id"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
self.user = synapse_admin.User(server, 443, access_token, protocol)
|
||||||
@dataclass
|
self.room = synapse_admin.Room(server, 443, access_token, protocol)
|
||||||
class RoomStateResponse(nio.responses.Response):
|
self.media = synapse_admin.Media(server, 443, access_token, protocol)
|
||||||
event_id: str = field()
|
self.management = synapse_admin.Management(
|
||||||
|
server, 443, access_token, protocol)
|
||||||
@staticmethod
|
|
||||||
def create_error(parsed_dict):
|
|
||||||
return nio.responses.ErrorResponse.from_dict(parsed_dict)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, parsed_dict: Dict[Any, Any]):
|
|
||||||
try:
|
|
||||||
nio.validate_json(parsed_dict, Schemas.room_state)
|
|
||||||
except (nio.SchemaError, nio.ValidationError):
|
|
||||||
return cls.create_error(parsed_dict)
|
|
||||||
|
|
||||||
return cls(parsed_dict["event_id"])
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixHelper:
|
class MatrixHelper:
|
||||||
def __init__(self, server, username, space_id: str) -> None:
|
def __init__(self, server: str, username, space_id: str, matrix_username: str) -> None:
|
||||||
self._client = nio.AsyncClient(server, username)
|
self._client = nio.AsyncClient(server, username)
|
||||||
self._loggedIn = False
|
self._loggedIn = False
|
||||||
self._space_id = space_id
|
self._space_id = space_id
|
||||||
|
self._server = server
|
||||||
|
self._username = username
|
||||||
|
self._matrix_username = matrix_username
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
asyncio.run(self.logout())
|
||||||
|
|
||||||
async def login(self, password):
|
async def login(self, password):
|
||||||
try:
|
try:
|
||||||
|
@ -46,15 +41,23 @@ class MatrixHelper:
|
||||||
logging.error("The bot user is not in the space!")
|
logging.error("The bot user is not in the space!")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# admin login
|
||||||
|
self._admin_client = MatrixAdminClient(
|
||||||
|
self._server, self._client.access_token)
|
||||||
|
user = self._admin_client.user.query(self._username)
|
||||||
|
|
||||||
|
logging.info(f"Logged into Synapse-admin as {user['name']}")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
logging.error(f"Error while logging into matrix server!")
|
logging.error(f"Error while logging into matrix server!")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def logout(self):
|
async def logout(self):
|
||||||
await self._client.close()
|
await self._client.close()
|
||||||
|
|
||||||
async def createRoom(self, name, alias):
|
async def create_room(self, name, alias, joinable_for_space_members=False):
|
||||||
# we not only need to create the room but also have to add it to the space
|
# we not only need to create the room but also have to add it to the space
|
||||||
initial_state = [
|
initial_state = [
|
||||||
{
|
{
|
||||||
|
@ -79,12 +82,64 @@ class MatrixHelper:
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "m.room.history_visibility",
|
"type": "m.room.history_visibility",
|
||||||
"content": {"history_visibility": "invited"}
|
"content": {"history_visibility": "shared"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "m.room.power_levels",
|
||||||
|
"content": {
|
||||||
|
"users": {
|
||||||
|
self._matrix_username: 200,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if joinable_for_space_members:
|
||||||
|
initial_state.append({
|
||||||
|
"type": "m.room.guest_access",
|
||||||
|
"state_key": "",
|
||||||
|
"content": {"guest_access": "can_join"}
|
||||||
|
})
|
||||||
|
|
||||||
room = await self._client.room_create(visibility=nio.RoomVisibility.private, name=name, alias=alias, federate=False, initial_state=initial_state)
|
room = await self._client.room_create(visibility=nio.RoomVisibility.private, name=name, alias=alias, federate=False, initial_state=initial_state)
|
||||||
|
|
||||||
print(room)
|
assert room.room_id is not None
|
||||||
logging.info(
|
|
||||||
f"Created room {room.room_id} with alias {alias} and added it to space {self._space_id}")
|
|
||||||
return room
|
return room
|
||||||
|
|
||||||
|
async def get_rooms(self):
|
||||||
|
return self._admin_client.room.lists()
|
||||||
|
|
||||||
|
async def get_users(self):
|
||||||
|
return self._admin_client.user.lists()
|
||||||
|
|
||||||
|
async def get_rooms_of_user(self, username):
|
||||||
|
return self._admin_client.user.joined_room(username)
|
||||||
|
|
||||||
|
async def add_user_to_room(self, user, room):
|
||||||
|
return self._admin_client.user.join_room(user, room)
|
||||||
|
|
||||||
|
async def remove_user_from_room(self, user, room):
|
||||||
|
# TODO: Ban users?
|
||||||
|
return await self._client.room_kick(room, user, "You have been removed automatically since you are no longer a member of the group associated with this room. If you think, this is an error, please contact us.")
|
||||||
|
|
||||||
|
async def get_room_power_levels(self, room):
|
||||||
|
current_state = await self._client.room_get_state_event(room, "m.room.power_levels")
|
||||||
|
return current_state.content['users']
|
||||||
|
|
||||||
|
async def set_user_power_level_in_room(self, user, room, power_level):
|
||||||
|
if user == self._matrix_username:
|
||||||
|
return False
|
||||||
|
if power_level != 100 and power_level != 50 and power_level != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
users = await self.get_room_power_levels(room)
|
||||||
|
users[user] = power_level
|
||||||
|
if not self._matrix_username in users:
|
||||||
|
users[self._matrix_username] = 200
|
||||||
|
|
||||||
|
|
||||||
|
return await self._client.room_put_state(room, "m.room.power_levels", {
|
||||||
|
"users": users
|
||||||
|
})
|
||||||
|
|
Loading…
Reference in a new issue