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.
This commit is contained in:
commit
6447362d04
10 changed files with 380 additions and 0 deletions
54
.forgejo/workflows/release.yml
Normal file
54
.forgejo/workflows/release.yml
Normal file
|
@ -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 }}
|
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
*.pyc
|
||||
__pycache__/
|
||||
venv/
|
||||
config.yaml
|
19
LICENSE
Normal file
19
LICENSE
Normal file
|
@ -0,0 +1,19 @@
|
|||
Copyright (c) 2024 Private.coffee Team <support@private.coffee>
|
||||
|
||||
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.
|
9
README.md
Normal file
9
README.md
Normal file
|
@ -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.
|
4
config.yaml.dist
Normal file
4
config.yaml.dist
Normal file
|
@ -0,0 +1,4 @@
|
|||
homeserver: "https://homeserver.example"
|
||||
username: "your_username"
|
||||
password: "your_password"
|
||||
operator_room_id: "!your_operator_room_id:homeserver.example"
|
39
pyproject.toml
Normal file
39
pyproject.toml
Normal file
|
@ -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"]
|
0
src/matrix_supportbot/__init__.py
Normal file
0
src/matrix_supportbot/__init__.py
Normal file
0
src/matrix_supportbot/classes/__init__.py
Normal file
0
src/matrix_supportbot/classes/__init__.py
Normal file
233
src/matrix_supportbot/classes/bot.py
Normal file
233
src/matrix_supportbot/classes/bot.py
Normal file
|
@ -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"]
|
||||
)
|
18
src/matrix_supportbot/main.py
Normal file
18
src/matrix_supportbot/main.py
Normal file
|
@ -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()
|
Loading…
Reference in a new issue