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