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