first commit
This commit is contained in:
commit
adfa17e274
8 changed files with 396 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
.venv
|
||||
venv
|
||||
*.mbp
|
||||
*.db
|
||||
*.log
|
||||
plugins/*
|
||||
config.yaml
|
0
README.md
Normal file
0
README.md
Normal file
21
base-config.yaml
Normal file
21
base-config.yaml
Normal file
|
@ -0,0 +1,21 @@
|
|||
update_interval: 5
|
||||
whitelist:
|
||||
- '@ravage:xentonix.net'
|
||||
rooms:
|
||||
'!XBHBxuegpdVZEJBOIh:xentonix.net':
|
||||
#maubot test room
|
||||
member_level: 50
|
||||
nomember_level: 0
|
||||
launchpad_groups: [matrix-council]
|
||||
remove_permissions: yes
|
||||
enabled: yes
|
||||
use_socials: yes
|
||||
'!SqVwRMDWgHlUMdMGzk:ubuntu.com':
|
||||
#read-only-test:ubuntu.com
|
||||
member_level: 1
|
||||
nomember_level: 0
|
||||
launchpad_groups: [read-only-trusted, matrix-council]
|
||||
remove_permissions: no
|
||||
use_socials: no
|
||||
enabled: yes
|
||||
|
14
maubot.yaml
Normal file
14
maubot.yaml
Normal file
|
@ -0,0 +1,14 @@
|
|||
id: com.ubuntu.roomadmin
|
||||
version: 0.0.4
|
||||
modules:
|
||||
- roomadmin
|
||||
main_class: Roomadmin
|
||||
maubot: 0.1.0
|
||||
database: false
|
||||
config: true
|
||||
webapp: false
|
||||
license: MIT
|
||||
extra_files:
|
||||
- base-config.yaml
|
||||
dependencies: []
|
||||
soft_dependencies: []
|
26
requirements.txt
Normal file
26
requirements.txt
Normal file
|
@ -0,0 +1,26 @@
|
|||
aiohttp==3.9.3
|
||||
aiosignal==1.3.1
|
||||
aiosqlite==0.18.0
|
||||
asyncpg==0.28.0
|
||||
attrs==23.2.0
|
||||
bcrypt==4.1.2
|
||||
click==8.1.7
|
||||
colorama==0.4.6
|
||||
commonmark==0.9.1
|
||||
frozenlist==1.4.1
|
||||
idna==3.6
|
||||
Jinja2==3.1.3
|
||||
MarkupSafe==2.1.5
|
||||
maubot==0.4.2
|
||||
mautrix==0.20.4
|
||||
multidict==6.0.5
|
||||
packaging==24.0
|
||||
prompt-toolkit==3.0.43
|
||||
questionary==1.10.0
|
||||
ruamel.yaml==0.17.40
|
||||
ruamel.yaml.clib==0.2.8
|
||||
schedule==1.2.1
|
||||
setuptools==69.2.0
|
||||
SQLAlchemy==1.3.24
|
||||
wcwidth==0.2.13
|
||||
yarl==1.9.4
|
2
roomadmin/__init__.py
Normal file
2
roomadmin/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
from .bot import Roomadmin
|
||||
__all__ = ["Roomadmin"]
|
301
roomadmin/bot.py
Normal file
301
roomadmin/bot.py
Normal file
|
@ -0,0 +1,301 @@
|
|||
from __future__ import annotations
|
||||
import json, os, re, requests, asyncio, traceback, logging
|
||||
from launchpadlib.launchpad import Launchpad
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
from typing import Type, Tuple
|
||||
from urllib.parse import urlparse, unquote
|
||||
from maubot import Plugin, MessageEvent
|
||||
from maubot.handlers import command, event
|
||||
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
|
||||
from mautrix.util import background_task
|
||||
from mautrix.types import (
|
||||
EventType,
|
||||
MemberStateEventContent,
|
||||
PowerLevelStateEventContent,
|
||||
RoomID,
|
||||
RoomAlias,
|
||||
StateEvent,
|
||||
UserID,
|
||||
Membership
|
||||
)
|
||||
from .floodprotection import FloodProtection
|
||||
|
||||
roomadmin_change_level = EventType.find("com.ubuntu.roomadmin", t_class=EventType.Class.STATE)
|
||||
|
||||
class Config(BaseProxyConfig):
|
||||
def do_update(self, helper: ConfigUpdateHelper) -> None:
|
||||
helper.copy("whitelist")
|
||||
helper.copy("rooms")
|
||||
helper.copy("update_interval")
|
||||
|
||||
class Roomadmin(Plugin):
|
||||
reminder_loop_task: asyncio.Future
|
||||
room_ids = []
|
||||
room_mapping = {}
|
||||
power_level_cache: dict[RoomID, tuple[int, PowerLevelStateEventContent]]
|
||||
|
||||
async def start(self) -> None:
|
||||
self.config.load_and_update()
|
||||
self.flood_protection = FloodProtection()
|
||||
self.power_level_cache = {}
|
||||
logger = logging.getLogger(self.id)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
self.log = logger
|
||||
if await self.resolve_room_aliases():
|
||||
self.poll_task = asyncio.create_task(self.poll_sync())
|
||||
self.log.info("Roomadmin started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
await super().stop()
|
||||
self.poll_task.cancel()
|
||||
|
||||
async def get_room_members(self, room_id):
|
||||
members = set()
|
||||
joined_members = await self.client.get_joined_members(room_id)
|
||||
if joined_members:
|
||||
for user_id in joined_members.keys():
|
||||
members.add(user_id)
|
||||
return set(members)
|
||||
|
||||
|
||||
@event.on(EventType.ROOM_MEMBER)
|
||||
async def handle_member_event(self, evt: StateEvent) -> None:
|
||||
if evt.content.membership == Membership.JOIN:
|
||||
user_id = evt.state_key
|
||||
room_id = evt.room_id
|
||||
joined_rooms = await self.client.get_joined_rooms()
|
||||
if room_id not in self.config["rooms"]:
|
||||
return False
|
||||
self.check_member_level(room_id, self.config["rooms"][room_id]["launchpad_groups"], user_id)
|
||||
|
||||
async def get_power_levels(self, room_id: RoomID) -> PowerLevelStateEventContent:
|
||||
try:
|
||||
expiry, levels = self.power_level_cache[room_id]
|
||||
if expiry < int(time()):
|
||||
return levels
|
||||
except KeyError:
|
||||
pass
|
||||
levels = await self.client.get_state_event(room_id, EventType.ROOM_POWER_LEVELS)
|
||||
now = int(time())
|
||||
self.power_level_cache[room_id] = (now + 5 * 60, levels)
|
||||
return levels
|
||||
|
||||
async def can_manage(self, evt: MessageEvent) -> bool:
|
||||
if evt.sender in self.config["whitelist"]:
|
||||
return True
|
||||
levels = await self.get_power_levels(evt.room_id)
|
||||
user_level = levels.get_user_level(evt.sender)
|
||||
state_level = levels.get_event_level(roomadmin_change_level)
|
||||
if not isinstance(state_level, int):
|
||||
state_level = 100
|
||||
if user_level < state_level:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def get_matrix_socials(self, mxid):
|
||||
try:
|
||||
if mxid.startswith('@') and ':' in mxid:
|
||||
profile_id = mxid.split(':')[0][1:]
|
||||
url = 'http://127.0.0.1:8000/launchpad/api/people/' + profile_id + '/socials/matrix'
|
||||
resp = await self.http.get(url)
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return data
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return False
|
||||
return False
|
||||
async def is_mxid_in_any_launchpad_group(self, mxid, groups):
|
||||
if mxid == '':
|
||||
return False
|
||||
for group in groups:
|
||||
gmembers = await self.get_launchpad_group_members(group)
|
||||
if gmembers:
|
||||
if mxid in gmembers['mxids']:
|
||||
return group
|
||||
return False
|
||||
|
||||
async def set_user_power_level(self, room_id, mxid, level):
|
||||
try:
|
||||
# Fetch the current power levels
|
||||
current_power_levels = await self.get_power_levels(room_id)
|
||||
# Update the power level for the specified user
|
||||
current_power_levels["users"][mxid] = level
|
||||
await self.client.send_state_event(
|
||||
room_id,
|
||||
"m.room.power_levels",
|
||||
current_power_levels,
|
||||
""
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return False
|
||||
|
||||
async def handle_sync(self, room_id, launchpad_groups):
|
||||
if not room_id in self.config["rooms"]:
|
||||
return False
|
||||
self.config["rooms"][room_id].setdefault("launchpad_groups", [])
|
||||
self.config["rooms"][room_id].setdefault("remove_permissions", [])
|
||||
self.config["rooms"][room_id].setdefault("enabled", 'yes')
|
||||
self.config["rooms"][room_id].setdefault("use_socials", 'no')
|
||||
if self.config["rooms"][room_id]["enabled"] != "yes":
|
||||
return False
|
||||
use_socials = self.config["rooms"][room_id]["use_socials"]
|
||||
room_members = await self.get_room_members(room_id)
|
||||
if room_members:
|
||||
room_members = set(room_members)
|
||||
for member in room_members:
|
||||
if member.endswith(':ubuntu.com'):
|
||||
if use_socials == "yes":
|
||||
social_ids = await self.get_matrix_socials(member)
|
||||
if social_ids:
|
||||
for social_id in social_ids:
|
||||
if not social_id.endswith(':ubuntu.com'):
|
||||
check = await self.check_member_level(room_id, launchpad_groups, member, social_id)
|
||||
else:
|
||||
check = await self.check_member_level(room_id, launchpad_groups, member)
|
||||
else:
|
||||
check = await self.check_member_level(room_id, launchpad_groups, member)
|
||||
|
||||
|
||||
async def check_member_level(self, room_id, launchpad_groups, member, social_id = ''):
|
||||
joined_rooms = await self.client.get_joined_rooms()
|
||||
# Check if the specified room ID is in the list of joined rooms
|
||||
try:
|
||||
if not room_id in joined_rooms:
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Failed to check room membership: {e}")
|
||||
|
||||
self.config["rooms"][room_id].setdefault("launchpad_groups", [])
|
||||
self.config["rooms"][room_id].setdefault("remove_permissions", 'yes')
|
||||
self.config["rooms"][room_id].setdefault("enabled", 'yes')
|
||||
levels = await self.get_power_levels(room_id)
|
||||
member_level = self.config["rooms"][room_id]["member_level"]
|
||||
nomember_level = self.config["rooms"][room_id]["nomember_level"]
|
||||
is_in_group = await self.is_mxid_in_any_launchpad_group(member, launchpad_groups)
|
||||
remove_permissions = self.config["rooms"][room_id]["remove_permissions"]
|
||||
if social_id != '':
|
||||
member = social_id
|
||||
user_level = levels.get_user_level(member)
|
||||
if user_level >= 100 or member == self.client.mxid:
|
||||
return False
|
||||
|
||||
if is_in_group == False and remove_permissions == 'yes' and user_level != nomember_level:
|
||||
msg = "User " + member + " is not in any of the room's launchpad groups, has a power level of " + str(user_level) + " and should have a level of " + str(nomember_level)
|
||||
#await self.client.send_notice(room_id, msg)
|
||||
await self.set_user_power_level(room_id, member, nomember_level)
|
||||
elif user_level > member_level and remove_permissions == 'yes' and is_in_group != False:
|
||||
msg = "User " + member + " in group " + str(is_in_group) + " has a power level of " + str(user_level) + " and should have a lower group level of " + str(member_level)
|
||||
#await self.client.send_notice(room_id, msg)
|
||||
await self.set_user_power_level(room_id, member, member_level)
|
||||
elif user_level < member_level and is_in_group != False:
|
||||
msg = "User " + str(member) + " in group " + str(is_in_group) + " has a power level of " + str(user_level) + " and should have a higher group level of " + str(member_level)
|
||||
#await self.client.send_notice(room_id, msg)
|
||||
await self.set_user_power_level(room_id, member, member_level)
|
||||
|
||||
async def get_launchpad_group_members(self, group_name):
|
||||
url = 'http://127.0.0.1:8000/launchpad/api/groups/members/' + str(group_name)
|
||||
try:
|
||||
resp = await self.http.get(url)
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return data
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return False
|
||||
return False
|
||||
|
||||
@command.new(name="lpsync", require_subcommand=False)
|
||||
async def roomadmin(self, evt: MessageEvent) -> None:
|
||||
self.config["rooms"][room_id].setdefault("launchpad_groups", [])
|
||||
self.config["rooms"][room_id].setdefault("remove_permissions", [])
|
||||
self.config["rooms"][room_id].setdefault("enabled", 'yes')
|
||||
enabled = self.config["rooms"][room_id]["enabled"]
|
||||
launchpad_groups = self.config["rooms"][evt.room_id]["launchpad_groups"]
|
||||
if enabled != "yes":
|
||||
return False
|
||||
if not await self.can_manage(evt) and self.flood_protection.flood_check(evt.sender):
|
||||
await evt.respond("You don't have the permission to use this command.")
|
||||
return False
|
||||
await evt.respond("Invalid argument. Example: !roomadmin sync")
|
||||
if not await self.can_manage(evt) and self.flood_protection.flood_check(evt.sender):
|
||||
await evt.respond("You don't have the permission to use this command.")
|
||||
return False
|
||||
|
||||
async def resolve_room_alias_to_id(self, room_alias: str):
|
||||
try:
|
||||
room_id = await self.client.resolve_room_alias(RoomAlias(room_alias))
|
||||
return room_id
|
||||
except Exception as e:
|
||||
self.log.error(f"Error resolving room alias {room_alias}: {e}")
|
||||
self.log.debug(traceback.format_exc())
|
||||
return None
|
||||
|
||||
async def resolve_room_aliases(self):
|
||||
self.room_ids = []
|
||||
self.room_mapping = {}
|
||||
for room_alias in self.config["rooms"]:
|
||||
if room_alias.startswith("#"):
|
||||
if room_id_obj := await self.resolve_room_alias_to_id(room_alias):
|
||||
room_id = str(room_id_obj.room_id)
|
||||
self.room_ids.append(room_id)
|
||||
self.room_mapping[room_id] = room_alias
|
||||
self.log.info("Added room " + room_alias + " with id " + room_id)
|
||||
elif room_alias.startswith("!"):
|
||||
self.room_ids.append(room_alias)
|
||||
self.room_mapping[room_alias] = room_alias
|
||||
self.log.info("Added room id " + room_alias)
|
||||
else:
|
||||
self.log.debug("Error addming room " + room_alias)
|
||||
return True
|
||||
|
||||
def check_access_sender(self, sender):
|
||||
if sender in self.config["whitelist"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def poll_sync(self) -> None:
|
||||
try:
|
||||
await self._poll_sync()
|
||||
except asyncio.CancelledError:
|
||||
self.log.info("Sync stopped")
|
||||
except Exception:
|
||||
self.log.exception("Fatal error while syncing")
|
||||
async def _poll_sync(self) -> None:
|
||||
self.log.info("Syncing started")
|
||||
while True:
|
||||
try:
|
||||
await self._sync_once()
|
||||
except asyncio.CancelledError:
|
||||
self.log.info("Syncing stopped")
|
||||
except Exception:
|
||||
self.log.exception("Error while syncing")
|
||||
self.log.debug("Sleeping " + str(self.config["update_interval"] * 60) + " seconds")
|
||||
await asyncio.sleep(self.config["update_interval"] * 60)
|
||||
async def _sync_once(self) -> None:
|
||||
try:
|
||||
for room_id in self.config["rooms"]:
|
||||
self.config["rooms"][room_id].setdefault("launchpad_groups", [])
|
||||
self.config["rooms"][room_id].setdefault("remove_permissions", [])
|
||||
self.config["rooms"][room_id].setdefault("enabled", 'yes')
|
||||
enabled = self.config["rooms"][room_id]["enabled"]
|
||||
launchpad_groups = self.config["rooms"][room_id]["launchpad_groups"]
|
||||
try:
|
||||
sync = await self.handle_sync(room_id, launchpad_groups)
|
||||
if sync:
|
||||
return True
|
||||
except Exception as e:
|
||||
self.log.debug(f"Error fetching members: {e}")
|
||||
self.log.debug(traceback.format_exc())
|
||||
pass
|
||||
except Exception as e:
|
||||
self.log.debug(f"Error syncing: {e}")
|
||||
self.log.debug(traceback.format_exc())
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_config_class(cls) -> Type[BaseProxyConfig]:
|
||||
return Config
|
25
roomadmin/floodprotection.py
Normal file
25
roomadmin/floodprotection.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
from collections import defaultdict
|
||||
from time import time
|
||||
|
||||
class FloodProtection:
|
||||
def __init__(self):
|
||||
self.user_commands = defaultdict(list) # Stores timestamps of commands for each user
|
||||
self.max_commands = 3
|
||||
self.time_window = 60 # 60 seconds
|
||||
|
||||
def flood_check(self, user_id):
|
||||
"""Check if a user can send a command based on flood protection limits."""
|
||||
current_time = time()
|
||||
if user_id not in self.user_commands:
|
||||
self.user_commands[user_id] = [current_time]
|
||||
return True # Allow the command if the user has no recorded commands
|
||||
|
||||
# Remove commands outside the time window
|
||||
self.user_commands[user_id] = [timestamp for timestamp in self.user_commands[user_id] if current_time - timestamp < self.time_window]
|
||||
|
||||
if len(self.user_commands[user_id]) < self.max_commands:
|
||||
self.user_commands[user_id].append(current_time)
|
||||
return True # Allow the command if under the limit
|
||||
|
||||
# Otherwise, do not allow the command
|
||||
return False
|
Loading…
Reference in a new issue