commit 1a27536e14295bc2351c222a1f79bd01c35b68d2 Author: AO-IT Net <> Date: Sun Feb 15 13:26:30 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c0f039 --- /dev/null +++ b/.gitignore @@ -0,0 +1,186 @@ +*.db +*.conf +*.log +*.bak +testing/ + +# ---> Python +tmp/ +*.tmp +.vscode/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a39d332 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# Template Bot for Matrix +This template run a basic matrix-bot with dynamic addable skills. +Skill Template: https://git.ao-it.net/templatecity/bot-matrix-skill + +## requirements +- python libs see requirements.txt +- matrix user + +## limits +- actually this bot is not designed for encryptet channels \ No newline at end of file diff --git a/bot.conf.sample b/bot.conf.sample new file mode 100644 index 0000000..32448b3 --- /dev/null +++ b/bot.conf.sample @@ -0,0 +1,11 @@ +--- +general: + botname: Bot Name + domain: example.com + # all without domain + user: "bot" + pass: "botpassword" + status_room: "!roomid" +skills: + # all to loading skills. optional with own configs + BaseSkills: \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..0ec2897 --- /dev/null +++ b/main.py @@ -0,0 +1,192 @@ +import logging +import argparse +import os +import asyncio +import yaml +import importlib +import pathlib +from nio import AsyncClient, LoginResponse + +from skills import * + +logging.basicConfig(format='[%(asctime)s] %(levelname)s: %(message)s', level=logging.INFO) + +class MatrixBot: + __AUTHOR__ = 'anima' + __VERSION__ = '0.6.1' + def __init__(self, config_file: str = 'bot.conf', session_file: str = 'session.tmp') -> None: + """ + Args: + config_file (str, optional): path to config file + session_file (str, optional): path to session file + """ + self.botname = 'Bot' + self.config_file = config_file + self.session_file = session_file + self._config = dict() + self.silence = False + self.skills = list() + self._find_skills() + + if self._load_config(): + self.client = AsyncClient(self.homeserver, self.username) + self.helper = BotHelper(self.client) + self._activate_skills() + else: + logging.critical('no config file was found') + exit(1) + + def _load_config(self) -> bool: + """load given config file""" + if not os.path.exists(self.config_file): + return False + logging.info(f'load config from file {self.config_file}') + with open(self.config_file, 'r') as f: + self._config = yaml.safe_load(f) + self.botname = self._config['general']['botname'] + self.domain = self._config['general']['domain'] + self.homeserver = f"https://{self.domain}" + self.username = f"@{self._config['general']['user']}:{self._config['general']['domain']}" + self.__password = self._config['general']['pass'] + del self._config['general']['pass'] + return True + + def _save_session(self) -> bool: + """save session data as file + + Returns: + bool: True if file was saved + """ + session = { + 'access_token': self.session.access_token, + 'device_id': self.session.device_id, + 'user_id': self.user_id, + 'next_batch': self.client.next_batch + } + logging.debug('save session file') + with open(self.session_file, 'w') as f: + yaml.dump(session, f) + return True + + def _load_session(self) -> bool: + """load session date from file + + Returns: + bool: True if file was found and load + """ + if not os.path.exists(self.session_file): + return False + logging.info(f'restore session from file {self.session_file}') + with open(self.session_file, 'r') as f: + session = yaml.safe_load(f) + self.client.access_token = session['access_token'] + self.client.device_id = session['device_id'] + self.client.user_id = session["user_id"] + self.client.next_batch = session['next_batch'] + return True + + async def _login(self) -> bool: + """restore login with session file or run full login process + + Returns: + bool: True when bot has a valid login + """ + if self._load_session(): + self.session = await self.client.sync(timeout=3000) + if self.session.transport_response.ok: + logging.debug('restore session successfully') + return True + else: + logging.warning('no session file was found or file is broken') + + logging.debug(f'try login with account {self.username}') + self.session = await self.client.login(self.__password) + if isinstance(session, LoginResponse): + logging.info(f'login successfull with account {self.username} on server {self.homeserver}') + self._save_session() + return True + return False + + def _find_skills(self): + """search all modules in skill folder and init these""" + skills_path = pathlib.Path(__file__).parent / "skills" + for module in skills_path.iterdir(): + if module.is_dir() and (module / "__init__.py").exists(): + importlib.import_module(f"skills.{module.name}") + + def _activate_skills(self): + """activate all skills which defined in config file""" + for skill in self._config['skills'].keys(): + self.load_skill(skill_name=skill, config=self._config['skills'][skill]) + + def load_skill(self, skill_name: str, config: dict = None) -> bool: + """load skill from skill registry + + Args: + skill_name (str): name of skill (same as classname) + config (dict, optional): additional config. Defaults to None. + + Returns: + bool: True is skill is loaded + """ + if not skill_name in Skill.registry.keys(): + return False + logging.info(f'load skill {skill_name} version {Skill.registry[skill_name].__VERSION__}') + skill = Skill.registry[skill_name](client=self.client, config=config) + skill.load() + self.skills.append(skill) + return True + + def unload_skill(self): + pass + + async def send_status(self, message: str) -> bool: + """send a message to a predefined room + + Args: + message (str): message content + + Returns: + bool: True if message send + """ + if self.silence: + return False + await self.helper.send_message(f"{self._config['general']['status_room']}:{self.domain}", message) + return True + + async def run(self): + if await self._login(): + await self.send_status(f"{self.botname} Online!") + await self.client.sync_forever(timeout=30000) + + +def parse_args() -> argparse.Namespace: + """evaluates given arguments + + Returns: + argsparse.Namespace: Namespace Object with all arguments insert (use: args.long_name_of_argument) + """ + argp = argparse.ArgumentParser(description=__doc__) + # Default args + argp.add_argument('-v', '--verbose', action='count', default=0, + help='increase output verbosity (use up to 3 times)') + argp.add_argument('-c', '--config', default='aoc.conf', + help='path to config file') + argp.add_argument('-s', '--session', default='session.tmp', + help='path to session file') + + args = argp.parse_args() + return args + +async def main(): + args = parse_args() + if 1 <= args.verbose <= 3: + logging.getLogger().setLevel(40 - (args.verbose * 10)) + bot = MatrixBot(config_file=args.config, session_file=args.session) + + bot.silence = True + await bot.run() + + +if __name__ == '__main__': + asyncio.run(main()) \ No newline at end of file diff --git a/requirments.txt b/requirments.txt new file mode 100644 index 0000000..d174cde --- /dev/null +++ b/requirments.txt @@ -0,0 +1,3 @@ +matrix-nio +requests +pyyaml \ No newline at end of file diff --git a/skills/__init__.py b/skills/__init__.py new file mode 100644 index 0000000..fb17bf3 --- /dev/null +++ b/skills/__init__.py @@ -0,0 +1,2 @@ +from .helper import BotHelper +from .skill import Skill \ No newline at end of file diff --git a/skills/helper.py b/skills/helper.py new file mode 100644 index 0000000..ed6e9b9 --- /dev/null +++ b/skills/helper.py @@ -0,0 +1,41 @@ +import logging +import time +import asyncio +from nio import RoomMessageText + +logging.basicConfig(format='[%(asctime)s] %(levelname)s: %(message)s', level=logging.INFO) + +class BotHelper: + """a helper class for frequently need actions""" + __AUTHOR__ = 'anima' + __VERSION__ = '0.0.2' + def __init__(self, client): + self.client = client + self.starttime = int(time.time() * 1000) + + def validate_message(self, event: RoomMessageText): + """validate incomming messages befor bot start his work""" + if self.client.user == event.sender: + logging.debug(f"no reaction on messgaes from bot it self") + return False + if self.starttime > event.server_timestamp: + logging.debug(f"no reaction on messgaes befor bot start") + return False + return True + + async def send_message(self, room: str, message: str): + """send a basic text message to a room + + Args: + room (str): room id without domain part (like !roomid) + message (str): simpl text message + """ + logging.debug(f'send message: {message}') + await self.client.room_send( + room, + message_type="m.room.message", + content={ + "msgtype": "m.text", + "body": message + } + ) \ No newline at end of file diff --git a/skills/skill.py b/skills/skill.py new file mode 100644 index 0000000..2bb584b --- /dev/null +++ b/skills/skill.py @@ -0,0 +1,39 @@ +import logging +from .helper import BotHelper + +logging.basicConfig(format='[%(asctime)s] %(levelname)s: %(message)s', level=logging.INFO) + +class Skill: + """abstract class for bot skills""" + __AUTHOR__ = 'anima' + __VERSION__ = '0.1.0' + __NAME__ = 'Example Skill' + + registry: dict[str, Skill] = {} + def __init_subclass__(cls, **kwargs): + """add loaded subclasses to regestry""" + super().__init_subclass__(**kwargs) + if cls.__name__ != "Skill" and issubclass(cls, Skill): + logging.info(f"add skill {cls.__name__} to regestry") + Skill.registry[cls.__name__] = cls + + def __init__(self, client: type, config: dict = None): + """minimal init for a skill class + + Args: + client (AsyncClient): bot client handler + config (dict, optional): additional configs from config file. Defaults to None. + """ + self.client = client + self.config = config + self.helper = BotHelper(self.client) + + def load(self): + """if skill activate it will run this class""" + raise NotImplementedError() + + +if __name__ == '__main__': + skill = Skill(None) + print(f"Load {skill.__NAME__} version {skill.__VERSION__}") + skill.load() \ No newline at end of file