Skip to content

Commit 417ce0e

Browse files
committed
initial Discord bot
1 parent 1a35d58 commit 417ce0e

28 files changed

+1235
-15
lines changed

.coveragerc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[run]
2+
omit = tests/*

.pre-commit-config.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
default_language_version:
2+
python: python3.11
3+
repos:
4+
- repo: https://github.com/pre-commit/pre-commit-hooks
5+
rev: v4.6.0
6+
hooks:
7+
- id: check-merge-conflict
8+
- id: check-shebang-scripts-are-executable
9+
- id: check-ast
10+
- id: trailing-whitespace
11+
- id: end-of-file-fixer
12+
- id: check-yaml
13+
- id: check-added-large-files
14+
- repo: https://github.com/psf/black-pre-commit-mirror
15+
rev: 24.8.0
16+
hooks:
17+
- id: black
18+
- repo: https://github.com/pycqa/isort
19+
rev: 5.13.2
20+
hooks:
21+
- id: isort

.vscode/launch.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
// Use IntelliSense to learn about possible attributes.
3+
// Hover to view descriptions of existing attributes.
4+
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5+
"version": "0.2.0",
6+
"configurations": [
7+
{
8+
"name": "Discord Bot",
9+
"type": "debugpy",
10+
"request": "launch",
11+
"module": "bridger.bot"
12+
}
13+
]
14+
}

.vscode/settings.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"python.testing.pytestArgs": [
3+
"tests"
4+
],
5+
"python.testing.unittestEnabled": false,
6+
"python.testing.pytestEnabled": true
7+
}

bridger/bot.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import os
2+
3+
from aiohttp import ClientConnectorError
4+
from discord import Intents
5+
from discord.ext import commands
6+
7+
from bridger.log import logger
8+
9+
try:
10+
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
11+
DISCORD_BOT_OWNER_ID = int(os.getenv("DISCORD_BOT_OWNER_ID"))
12+
except TypeError as e:
13+
logger.error(f"Failed to load environment variable {e}")
14+
exit(1)
15+
16+
17+
class BridgerBot(commands.Bot):
18+
def __init__(self, **kwargs):
19+
super().__init__(command_prefix="./bridger ", **kwargs)
20+
21+
self.initial_extensions = [
22+
"bridger.cogs.mqtt",
23+
]
24+
25+
async def setup_hook(self):
26+
for ext in self.initial_extensions:
27+
await self.load_extension(ext)
28+
29+
30+
intents = Intents.default()
31+
intents.message_content = True
32+
intents.members = True
33+
34+
bot = BridgerBot(
35+
intents=intents,
36+
owner_id=DISCORD_BOT_OWNER_ID,
37+
)
38+
39+
40+
@commands.is_owner()
41+
@bot.command(name="sync-commands", description="Sync the commands with the database")
42+
async def sync_commands(ctx: commands.Context):
43+
try:
44+
await ctx.bot.tree.sync()
45+
await ctx.send("Commands synced to all guilds")
46+
except Exception as e:
47+
await ctx.send(f"Error syncing commands: {e}")
48+
49+
50+
try:
51+
bot.run(DISCORD_BOT_TOKEN)
52+
except ClientConnectorError as e:
53+
logger.error(f"Failed to connect to Discord {e}")
54+
exit(1)

bridger/cogs/mqtt.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import os
2+
3+
from discord import Embed, Interaction, app_commands
4+
from discord.ext import commands
5+
from discord.utils import get
6+
7+
from bridger.gateway import GatewayError, GatewayManagerEMQX, emqx
8+
from bridger.log import logger
9+
10+
BRIDGER_ADMIN_ROLE = os.getenv("BRIDGER_ADMIN_ROLE", "Bridger Admin")
11+
12+
13+
def check_gateway_owner(interaction: Interaction) -> bool:
14+
node_id = None
15+
gateway_manager = GatewayManagerEMQX(emqx)
16+
17+
logger.debug(f"Interaction data: {interaction.data}")
18+
19+
if "options" in interaction.data:
20+
# Look through nested options
21+
for option in interaction.data["options"]:
22+
if "options" in option:
23+
for sub_option in option["options"]:
24+
if sub_option["name"] == "node_id":
25+
node_id = sub_option["value"]
26+
break
27+
28+
if not node_id:
29+
raise ValueError("node_id not found in the command options")
30+
31+
logger.debug(f"Node ID: {node_id}")
32+
33+
try:
34+
gateway = gateway_manager.get_gateway(node_id)
35+
owner = interaction.client.get_user(gateway.owner_id)
36+
except ValueError as e:
37+
raise app_commands.AppCommandError(f"Error retrieving gateway: {e}")
38+
39+
logger.debug(f"Gateway owner: {owner}")
40+
logger.debug(f"Interaction user: {interaction.user}")
41+
42+
compared = owner == interaction.user
43+
44+
logger.debug(f"Owner and interaction user compared: {compared}")
45+
46+
return owner == interaction.user
47+
48+
49+
def is_bridger_admin_or_owner(interaction: Interaction):
50+
bridger_admin_role = get(interaction.guild.roles, name=BRIDGER_ADMIN_ROLE)
51+
if bridger_admin_role in interaction.user.roles or check_gateway_owner(interaction):
52+
return True
53+
return False
54+
55+
56+
class MQTTCog(commands.GroupCog, name="bridger-mqtt"):
57+
delete_after = None
58+
59+
def __init__(self, bot: commands.Bot, gateway_manager: GatewayManagerEMQX):
60+
self.bot = bot
61+
self.gateway_manager = gateway_manager
62+
63+
async def cog_app_command_error(self, interaction: Interaction, error: app_commands.AppCommandError):
64+
# Log the type of error and error message
65+
logger.debug(f"App command error: {type(error)}: {error}")
66+
67+
if isinstance(error, app_commands.errors.CommandInvokeError):
68+
if isinstance(error.original, GatewayError):
69+
await interaction.response.send_message(
70+
f"Gateway already exists: {error.original.gateway.node_hex_id}",
71+
ephemeral=True,
72+
delete_after=self.delete_after,
73+
)
74+
else:
75+
await interaction.response.send_message(
76+
f"Command invoke error: {error.original}", ephemeral=True, delete_after=self.delete_after
77+
)
78+
elif isinstance(error, (app_commands.errors.MissingRole, app_commands.errors.CheckFailure)):
79+
await interaction.response.send_message(
80+
f"Check failure: {error}", ephemeral=True, delete_after=self.delete_after
81+
)
82+
elif isinstance(error, ValueError):
83+
await interaction.response.send_message(f"Value error: {error}", ephemeral=True, delete_after=self.delete_after)
84+
else:
85+
await interaction.response.send_message(
86+
f"Unknown error: {error}", ephemeral=True, delete_after=self.delete_after
87+
)
88+
89+
@app_commands.command(name="request-account", description="Request a new MQTT account")
90+
@app_commands.describe(
91+
node_id="The hex node ID to request an account for. With or without the preceding ! such as !cbaf0421 or cbaf0421"
92+
)
93+
async def request_account(self, ctx: Interaction, node_id: str):
94+
gateway, password = self.gateway_manager.create_gateway_user(node_id, ctx.user)
95+
message = f"Gateway created: {gateway.node_hex_id} with password: {password}"
96+
97+
await ctx.response.send_message(message, ephemeral=True)
98+
99+
@app_commands.checks.has_role(BRIDGER_ADMIN_ROLE)
100+
@app_commands.command(name="delete-account", description="Delete MQTT account")
101+
async def delete_account(self, ctx: Interaction, node_id: str):
102+
if self.gateway_manager.delete_gateway_user(node_id, ctx.user):
103+
await ctx.response.send_message(f"Gateway deleted: {node_id}", ephemeral=True, delete_after=self.delete_after)
104+
else:
105+
await ctx.response.send_message(f"Gateway not found: {node_id}", ephemeral=True, delete_after=self.delete_after)
106+
107+
@app_commands.checks.has_role(BRIDGER_ADMIN_ROLE)
108+
@app_commands.command(name="list-accounts", description="List all MQTT accounts")
109+
async def list_accounts(self, ctx: Interaction):
110+
gateways = self.gateway_manager.list_gateways()
111+
112+
if not gateways:
113+
await ctx.response.send_message(content="There are no provisioned gateways in the system.", ephemeral=True)
114+
return
115+
116+
embed = Embed(description="Currently provisioned gateways:", color=0x6CEB94)
117+
118+
for gateway in gateways:
119+
owner = self.bot.get_user(gateway.owner_id)
120+
121+
embed.add_field(
122+
name="Gateway",
123+
value=f"ID: **{gateway.node_hex_id}**\nOwner: **{owner.name}**",
124+
inline=True,
125+
)
126+
127+
await ctx.response.send_message(
128+
content=f"There are {len(gateways)} provisioned gateways in the system.",
129+
embed=embed,
130+
ephemeral=True,
131+
)
132+
133+
@app_commands.check(check_gateway_owner)
134+
@app_commands.command(name="reset-password", description="Reset MQTT account password")
135+
async def reset_password(self, ctx: Interaction, node_id: str):
136+
gateway, password = self.gateway_manager.reset_gateway_password(node_id, ctx.user)
137+
138+
await ctx.response.send_message(
139+
f"Gateway password reset: {gateway.node_hex_id} with new password: {password}",
140+
ephemeral=True,
141+
)
142+
143+
144+
async def setup(bot):
145+
gateway_manager = GatewayManagerEMQX(emqx)
146+
await bot.add_cog(MQTTCog(bot, gateway_manager))
File renamed without changes.

bridger/emqx/__init__.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from urllib.parse import urljoin
2+
3+
import requests
4+
from requests.auth import HTTPBasicAuth
5+
6+
from .api import ApiMixin
7+
from .authentication import AuthenticationMixin
8+
from .authorization import AuthorizationMixin
9+
10+
11+
class EMQXClient(ApiMixin, AuthenticationMixin, AuthorizationMixin):
12+
def __init__(self, base_url, api_key, secret_key, prefix="/api/v5"):
13+
self.base_url = base_url
14+
self.prefix = prefix
15+
self.auth = HTTPBasicAuth(api_key, secret_key)
16+
17+
def _handle_response(self, response):
18+
response.raise_for_status()
19+
20+
if response.status_code in [204]:
21+
return response.text
22+
return response.json()
23+
24+
def _request(self, method, endpoint, data=None, params=None) -> requests.Response:
25+
url = urljoin(self.base_url, f"{self.prefix}{endpoint}")
26+
headers = {"Content-Type": "application/json"}
27+
response = requests.request(method, url, auth=self.auth, headers=headers, json=data, params=params)
28+
return response

bridger/emqx/api.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
class ApiMixin:
2+
def list_api_keys(self):
3+
endpoint = "/api_key"
4+
return self._request("GET", endpoint)
5+
6+
def create_api_key(self, key_name, secret, role="administrator"):
7+
endpoint = "/api_key"
8+
data = {"key_name": key_name, "secret": secret, "role": role}
9+
return self._request("POST", endpoint, data=data)
10+
11+
def get_api_key(self, key_id):
12+
endpoint = f"/api_key/{key_id}"
13+
return self._request("GET", endpoint)
14+
15+
def update_api_key(self, key_id, key_name, secret, role):
16+
endpoint = f"/api_key/{key_id}"
17+
data = {"key_name": key_name, "secret": secret, "role": role}
18+
return self._request("PUT", endpoint, data=data)
19+
20+
def delete_api_key(self, key_id):
21+
endpoint = f"/api_key/{key_id}"
22+
return self._request("DELETE", endpoint)

bridger/emqx/authentication.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
class AuthenticationMixin:
2+
def list_users(self, authentication_id):
3+
endpoint = f"/authentication/{authentication_id}/users"
4+
response = self._request("GET", endpoint)
5+
return self._handle_response(response)
6+
7+
def get_user(self, authentication_id, user_id):
8+
endpoint = f"/authentication/{authentication_id}/users/{user_id}"
9+
return self._request("GET", endpoint)
10+
11+
def create_user(self, authentication_id, user_id, password, is_superuser=False):
12+
endpoint = f"/authentication/{authentication_id}/users"
13+
data = {"user_id": user_id, "password": password, "is_superuser": is_superuser}
14+
request = self._request("POST", endpoint, data=data)
15+
return self._handle_response(request)
16+
17+
def list_authentication(self):
18+
endpoint = "/authentication"
19+
request = self._request("GET", endpoint)
20+
return self._handle_response(request)
21+
22+
def get_authentication(self, authentication_id):
23+
endpoint = f"/authentication/{authentication_id}"
24+
request = self._request("GET", endpoint)
25+
return self._handle_response(request)
26+
27+
def delete_user(self, authentication_id, user_id):
28+
endpoint = f"/authentication/{authentication_id}/users/{user_id}"
29+
request = self._request("DELETE", endpoint)
30+
return self._handle_response(request)
31+
32+
def update_user_password(self, authentication_id, user_id, password):
33+
endpoint = f"/authentication/{authentication_id}/users/{user_id}"
34+
data = {"password": password}
35+
request = self._request("PUT", endpoint, data=data)
36+
return self._handle_response(request)

0 commit comments

Comments
 (0)