From 6447362d04d5d3baa46a9d0ea451a82e0c3043ee Mon Sep 17 00:00:00 2001 From: Kumi Date: Sun, 18 Aug 2024 17:33:56 +0200 Subject: [PATCH] feat: add initial version of Matrix Support Bot - Set up CI/CD workflow for Python package publishing to PyPI - Add MIT License for project - Create .gitignore file to exclude common Python and project files - Document project purpose and licensing in README.md - Add example configuration file (config.yaml.dist) - Define package metadata and dependencies in pyproject.toml - Implement SupportBot class for handling support tickets in Matrix - Implement main script for bot execution and configuration loading This initial commit establishes the structure and core functionality for the Matrix Support Bot project. --- .forgejo/workflows/release.yml | 54 +++++ .gitignore | 4 + LICENSE | 19 ++ README.md | 9 + config.yaml.dist | 4 + pyproject.toml | 39 ++++ src/matrix_supportbot/__init__.py | 0 src/matrix_supportbot/classes/__init__.py | 0 src/matrix_supportbot/classes/bot.py | 233 ++++++++++++++++++++++ src/matrix_supportbot/main.py | 18 ++ 10 files changed, 380 insertions(+) create mode 100644 .forgejo/workflows/release.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config.yaml.dist create mode 100644 pyproject.toml create mode 100644 src/matrix_supportbot/__init__.py create mode 100644 src/matrix_supportbot/classes/__init__.py create mode 100644 src/matrix_supportbot/classes/bot.py create mode 100644 src/matrix_supportbot/main.py diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..08e6ff7 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,54 @@ +name: Python Package CI/CD + +on: + workflow_dispatch: + push: + tags: + - "*" + +jobs: + setup: + name: Setup and Test + container: + image: node:20-bookworm + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + apt update + apt install -y python3-venv + + - name: Set up Python environment + run: | + python3 -V + python3 -m venv venv + . ./venv/bin/activate + pip install -U pip + pip install .[all] + + publish: + name: Publish to PyPI + container: + image: node:20-bookworm + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + apt update + apt install -y python3-venv + + - name: Publish to PyPI + run: | + python3 -m venv venv + . ./venv/bin/activate + pip install -U hatchling twine build + python -m build . + python -m twine upload --username __token__ --password ${PYPI_TOKEN} dist/* + env: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa4c190 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.pyc +__pycache__/ +venv/ +config.yaml \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8089469 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024 Private.coffee Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9376a04 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# Matrix-SupportBot + +[![Support Private.coffee!](https://shields.private.coffee/badge/private.coffee-support%20us!-pink?logo=coffeescript)](https://private.coffee) + +This is a simple, no-database support chat bot for Matrix. It is designed to be easy to use and lightweight. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/config.yaml.dist b/config.yaml.dist new file mode 100644 index 0000000..4cbad45 --- /dev/null +++ b/config.yaml.dist @@ -0,0 +1,4 @@ +homeserver: "https://homeserver.example" +username: "your_username" +password: "your_password" +operator_room_id: "!your_operator_room_id:homeserver.example" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0a9e7fb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[project] +name = "matrix-supportbot" +version = "0.1.0" + +authors = [{ name = "Private.coffee Team", email = "support@private.coffee" }] + +description = "Simple support chat bot for Matrix" +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.10" + +packages = ["src/matrix_supportbot"] + +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] + +dependencies = [ + "matrix-nio>=0.24.0", +] + +[project.urls] +"Homepage" = "https://git.private.coffee/PrivateCoffee/matrix-supportbot" +"Bug Tracker" = "https://git.private.coffee/PrivateCoffee/matrix-supportbot/issues" + +[project.scripts] +supportbot = "matrix_supportbot.main:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/matrix_supportbot"] diff --git a/src/matrix_supportbot/__init__.py b/src/matrix_supportbot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/matrix_supportbot/classes/__init__.py b/src/matrix_supportbot/classes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/matrix_supportbot/classes/bot.py b/src/matrix_supportbot/classes/bot.py new file mode 100644 index 0000000..0f245df --- /dev/null +++ b/src/matrix_supportbot/classes/bot.py @@ -0,0 +1,233 @@ +import random +from nio import ( + AsyncClient, + MatrixRoom, + RoomMessageText, + RoomMessageMedia, + LoginResponse, + RoomGetStateEventResponse, + RoomMemberEvent, +) + + +class SupportBot: + def __init__(self, config): + self.client = AsyncClient(config["homeserver"]) + self.username = config["username"] + self.password = config["password"] + self.operator_room_id = config["operator_room_id"] + + async def login(self): + response = await self.client.login(self.username, self.password) + if isinstance(response, LoginResponse): + print("Logged in successfully") + else: + print("Failed to log in") + + async def is_operator(self, user_id): + response = await self.client.joined_members(self.operator_room_id) + if isinstance(response, RoomMemberEvent): + return user_id in response.members + return False + + async def start(self): + self.client.add_event_callback(self.message_callback, RoomMessageText) + self.client.add_event_callback(self.message_callback, RoomMessageMedia) + await self.client.sync_forever(timeout=30000) + + async def message_callback(self, room: MatrixRoom, event): + sender = event.sender + body = event.body if hasattr(event, "body") else None + + if body and body.startswith("!supportbot"): + await self.handle_command(room, sender, body) + else: + await self.relay_message(room, sender, event) + + async def handle_command(self, room, sender, command): + if command == "!supportbot openticket": + await self.open_ticket(room, sender) + elif await self.is_operator(sender): + if command.startswith("!supportbot invite"): + await self.invite_operator(room, sender, command) + elif command.startswith("!supportbot close"): + await self.close_ticket(room, sender, command) + elif command == "!supportbot list": + await self.list_tickets(room) + else: + await self.client.room_send( + room.room_id, + "m.room.message", + { + "msgtype": "m.text", + "body": "You are not authorized to use this command.", + }, + ) + + def generate_ticket_id(self): + return str(random.randint(10000000, 99999999)) + + async def open_ticket(self, room, sender): + ticket_id = self.generate_ticket_id() + customer_room_alias = f"Ticket-{ticket_id}" + operator_room_alias = f"Operator-Ticket-{ticket_id}" + + # Create customer-facing room + customer_room = await self.client.room_create( + name=customer_room_alias, invite=[sender] + ) + customer_room_id = customer_room.room_id + + # Create operator-facing room + operator_room = await self.client.room_create(name=operator_room_alias) + operator_room_id = operator_room.room_id + + # Update the state in the operator room + state_event_key = f"ticket_{ticket_id}" + state_event_content = { + "ticket_id": ticket_id, + "customer_room": customer_room_id, + "operator_room": operator_room_id, + "status": "open", + } + + await self.client.room_put_state( + room_id=self.operator_room_id, + event_type="m.room.custom.ticket", + state_key=state_event_key, + content=state_event_content, + ) + + # Inform the operator room + await self.client.room_send( + self.operator_room_id, + "m.room.message", + { + "msgtype": "m.text", + "body": f"New ticket #{ticket_id} created by {sender}", + }, + ) + + # Inform customer + await self.client.room_send( + customer_room_id, + "m.room.message", + { + "msgtype": "m.text", + "body": f"Your ticket #{ticket_id} has been created. Please wait for an operator.", + }, + ) + + async def invite_operator(self, room, sender, command): + ticket_id = command.split()[2] + state_event_key = f"ticket_{ticket_id}" + response = await self.client.room_get_state_event( + self.operator_room_id, "m.room.custom.ticket", state_event_key + ) + + if isinstance(response, RoomGetStateEventResponse): + operator_room_id = response.content["operator_room"] + await self.client.room_invite(operator_room_id, sender) + else: + await self.client.room_send( + room.room_id, + "m.room.message", + {"msgtype": "m.text", "body": f"Ticket #{ticket_id} does not exist."}, + ) + + async def close_ticket(self, room, sender, command): + parts = command.split() + if len(parts) == 2: + ticket_id = parts[1] + else: + ticket_id = await self.get_ticket_id_from_room(room.room_id) + + state_event_key = f"ticket_{ticket_id}" + response = await self.client.room_get_state_event( + self.operator_room_id, "m.room.custom.ticket", state_event_key + ) + + if isinstance(response, RoomGetStateEventResponse): + ticket_info = response.content + customer_room_id = ticket_info["customer_room"] + operator_room_id = ticket_info["operator_room"] + + # Update ticket status + ticket_info["status"] = "closed" + await self.client.room_put_state( + room_id=self.operator_room_id, + event_type="m.room.custom.ticket", + state_key=state_event_key, + content=ticket_info, + ) + + await self.client.room_send( + customer_room_id, + "m.room.message", + {"msgtype": "m.text", "body": f"Ticket #{ticket_id} has been closed."}, + ) + await self.client.room_send( + operator_room_id, + "m.room.message", + {"msgtype": "m.text", "body": f"Ticket #{ticket_id} has been closed."}, + ) + else: + await self.client.room_send( + room.room_id, + "m.room.message", + {"msgtype": "m.text", "body": f"Ticket #{ticket_id} does not exist."}, + ) + + async def list_tickets(self, room): + response = await self.client.room_get_state(self.operator_room_id) + open_tickets = [] + + for event in response.events: + if event["type"] == "m.room.custom.ticket": + ticket_info = event["content"] + if ticket_info["status"] == "open": + open_tickets.append(ticket_info["ticket_id"]) + + await self.client.room_send( + room.room_id, + "m.room.message", + { + "msgtype": "m.text", + "body": f"Open tickets: {', '.join(map(str, open_tickets))}", + }, + ) + + async def get_ticket_id_from_room(self, room_id): + # This method will be used to find the ticket ID based on the room ID + # It will iterate over the state events in the operator room to find the matching ticket + response = await self.client.room_get_state(self.operator_room_id) + for event in response.events: + if event["type"] == "m.room.custom.ticket": + ticket_info = event["content"] + if ( + ticket_info["customer_room"] == room_id + or ticket_info["operator_room"] == room_id + ): + return ticket_info["ticket_id"] + return None + + async def relay_message(self, room, sender, event): + ticket_id = await self.get_ticket_id_from_room(room.room_id) + if ticket_id: + state_event_key = f"ticket_{ticket_id}" + response = await self.client.room_get_state_event( + self.operator_room_id, "m.room.custom.ticket", state_event_key + ) + + if isinstance(response, RoomGetStateEventResponse): + ticket_info = response.content + target_room_id = ( + ticket_info["operator_room"] + if room.room_id == ticket_info["customer_room"] + else ticket_info["customer_room"] + ) + + # Relay the entire event content to the target room + await self.client.room_send( + target_room_id, event.type, event.source["content"] + ) diff --git a/src/matrix_supportbot/main.py b/src/matrix_supportbot/main.py new file mode 100644 index 0000000..8515d7a --- /dev/null +++ b/src/matrix_supportbot/main.py @@ -0,0 +1,18 @@ +import yaml + +import asyncio + +from .bot import SupportBot + +def load_config(config_file): + with open(config_file, 'r') as file: + return yaml.safe_load(file) + +def main(): + config = load_config("config.yaml") + bot = SupportBot(config) + asyncio.get_event_loop().run_until_complete(bot.login()) + asyncio.get_event_loop().run_until_complete(bot.start()) + +if __name__ == "__main__": + main() \ No newline at end of file