30 Commits

Author SHA1 Message Date
Logan Cusano
b65bea7856 create stable and nightly builds
All checks were successful
Lint / lint (push) Successful in 19s
release-tag / release-image (push) Successful in 1h6m26s
2025-07-13 01:03:03 -04:00
Logan Cusano
bd8deeb44e Fix typo in the presence endpoint
All checks were successful
release-tag / release-image (push) Successful in 1h15m13s
Lint / lint (push) Successful in 11s
2025-07-06 19:34:27 -04:00
7f455f427e Merge pull request 'implement-bot-presence' (#6) from implement-bot-presence into master
All checks were successful
release-tag / release-image (push) Successful in 1h14m4s
Lint / lint (push) Successful in 10s
Reviewed-on: #6
2025-06-29 15:56:03 -04:00
Logan Cusano
ddfa9fc2a3 Linting
All checks were successful
Lint / lint (pull_request) Successful in 6s
2025-06-29 15:53:15 -04:00
Logan Cusano
fb9f8a680f Implement presence change
Some checks failed
Lint / lint (pull_request) Failing after 7s
2025-06-29 15:24:19 -04:00
Logan Cusano
a26dd619b8 Update set presence in bot manager 2025-06-29 15:22:20 -04:00
Logan Cusano
133f29635e Update gitignore for venv 2025-06-29 15:20:44 -04:00
Logan Cusano
cbc2a3fc86 Return the active token in get status
Some checks failed
release-tag / release-image (push) Successful in 1h14m8s
Lint / lint (push) Failing after 10s
2025-06-29 02:44:10 -04:00
Logan Cusano
b3a5dbb626 Revert "Update docker to use supervisor"
Some checks failed
release-tag / release-image (push) Successful in 1h13m56s
Lint / lint (push) Failing after 9s
This reverts commit 3086da0e2b.
2025-06-23 01:11:50 -04:00
Logan Cusano
44684ed020 Revert "Add custom liquid and service files and updated docker"
This reverts commit 021f27d62e.
2025-06-23 01:11:18 -04:00
Logan Cusano
5ff1d6273f Revert "Update docker to include liquidsoap"
This reverts commit d3e7e780f3.
2025-06-23 01:09:39 -04:00
Logan Cusano
6324f82789 Update the OP25 generator model to take either a string or an int
Some checks failed
release-tag / release-image (push) Successful in 1h14m31s
Lint / lint (push) Failing after 12s
2025-06-22 23:25:13 -04:00
Logan Cusano
3086da0e2b Update docker to use supervisor
Some checks failed
Lint / lint (push) Failing after 6s
release-tag / release-image (push) Has been cancelled
2025-05-29 01:02:07 -04:00
Logan Cusano
021f27d62e Add custom liquid and service files and updated docker
Some checks failed
release-tag / release-image (push) Successful in 1h14m39s
Lint / lint (push) Failing after 7s
2025-05-28 23:43:57 -04:00
Logan Cusano
d3e7e780f3 Update docker to include liquidsoap
Some checks failed
release-tag / release-image (push) Failing after 1m54s
Lint / lint (push) Failing after 32s
2025-05-28 23:28:56 -04:00
Logan Cusano
872bbf2965 Ignore reconnection logic when disconnecting
Some checks failed
release-tag / release-image (push) Successful in 1h14m15s
Lint / lint (push) Failing after 10s
2025-05-02 22:18:16 -04:00
Logan Cusano
e7956577d7 Replace old classes
Some checks failed
Lint / lint (push) Waiting to run
release-tag / release-image (push) Has been cancelled
2025-05-02 21:59:20 -04:00
Logan Cusano
c31984e2d8 Remove channel ID req from leave channel as it's not used
Some checks failed
Lint / lint (push) Waiting to run
release-tag / release-image (push) Has been cancelled
2025-05-02 21:57:28 -04:00
Logan Cusano
5191433e5d Add wait to joining voice
Some checks failed
release-tag / release-image (push) Successful in 1h14m57s
Lint / lint (push) Failing after 12s
2025-04-27 03:29:06 -04:00
Logan Cusano
9dfee88789 Add missed param
Some checks failed
Lint / lint (push) Waiting to run
release-tag / release-image (push) Has been cancelled
2025-04-27 03:18:50 -04:00
Logan Cusano
46ec27c359 Update bot manager to respond to start bot when bot is ready
Some checks failed
Lint / lint (push) Waiting to run
release-tag / release-image (push) Has been cancelled
2025-04-27 03:15:15 -04:00
Logan Cusano
75b2d0007d Add deploy option for make
Some checks failed
release-tag / release-image (push) Failing after 1m41s
Lint / lint (push) Successful in 20s
2025-03-08 20:17:04 -05:00
c616acd6af update gitignore
Some checks failed
release-tag / release-image (push) Failing after 1m49s
Lint / lint (push) Successful in 23s
2025-03-08 18:44:33 -05:00
Logan Cusano
7e44e0e803 Typo in build
Some checks failed
release-tag / release-image (push) Failing after 1m38s
Lint / lint (push) Successful in 19s
2025-03-04 22:04:36 -05:00
2418ac2701 Merge pull request 'Improving reconnection logic' (#2) from fix-disconnection-bug into master
Some checks failed
release-tag / release-image (push) Failing after 46s
Lint / lint (push) Successful in 19s
Reviewed-on: #2
2025-03-04 22:02:32 -05:00
f9d30b0c8b Linting
All checks were successful
Lint / lint (pull_request) Successful in 19s
2025-03-04 22:00:02 -05:00
a5ff9fa1be Merge branch 'master' into fix-disconnection-bug
Some checks failed
Lint / lint (pull_request) Failing after 18s
2025-03-04 21:47:41 -05:00
5b3f9bfaba Fix dockerfile and bot_manager
Some checks failed
Lint / lint (pull_request) Failing after 19s
2025-03-04 21:45:57 -05:00
Logan Cusano
9f10914b8b Remove extra container
Some checks failed
Lint / lint (push) Successful in 1m6s
release-tag / release-image (push) Failing after 50s
2025-03-01 01:56:28 -05:00
Logan Cusano
2c3c372da1 Potential Workaround dns issue
Some checks failed
Lint / lint (push) Failing after 20s
release-tag / release-image (push) Failing after 20s
2025-03-01 01:36:08 -05:00
15 changed files with 400 additions and 239 deletions

View File

@@ -0,0 +1,57 @@
name: release-tag
on:
push:
branches:
- dev
jobs:
release-image:
runs-on: ubuntu-latest
env:
DOCKER_LATEST: stable
CONTAINER_NAME: drb-client-discord-bot
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3
with: # replace it with your local IP
config-inline: |
[registry."git.vpn.cusano.net"]
http = false
insecure = false
- name: Login to DockerHub
uses: docker/login-action@v3
with:
registry: git.vpn.cusano.net # replace it with your local IP
username: ${{ secrets.GIT_REPO_USERNAME }}
password: ${{ secrets.GIT_REPO_PASSWORD }}
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Validate build configuration
uses: docker/build-push-action@v6
with:
call: check
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: |
linux/arm64
push: true
tags: | # replace it with your local IP and tags
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ env.DOCKER_LATEST }}

View File

@@ -8,10 +8,8 @@ on:
jobs: jobs:
release-image: release-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
env: env:
DOCKER_LATEST: nightly DOCKER_LATEST: stable
CONTAINER_NAME: drb-client-discord-bot CONTAINER_NAME: drb-client-discord-bot
steps: steps:
- name: Checkout - name: Checkout
@@ -52,7 +50,7 @@ jobs:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: | platforms: |
linux/arm4 linux/arm64
push: true push: true
tags: | # replace it with your local IP and tags tags: | # replace it with your local IP and tags
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}

3
.gitignore vendored
View File

@@ -2,4 +2,5 @@ __pycache__*
bot-poc.py bot-poc.py
configs* configs*
.env .env
*.log *.log*
.venv

View File

@@ -61,7 +61,7 @@ VOLUME ["/configs"]
WORKDIR /app WORKDIR /app
# Copy opus first to break up the build time # Copy opus first to break up the build time
COPY ./app/opus /app/opus COPY ./app/internal/opus /app/internal/opus
# Copy the rest of the directory contents into the container at /app # Copy the rest of the directory contents into the container at /app
COPY ./app /app COPY ./app /app

View File

@@ -23,6 +23,16 @@ run: build
--network=host \ --network=host \
$(IMAGE_NAME) $(IMAGE_NAME)
# Deploy docker
deploy: build
docker run --rm -d \
--privileged \
-v /dev:/dev \
-v $(shell pwd)/configs:/configs \
--name $(CONTAINER_NAME) \
--network=host \
$(IMAGE_NAME)
# Stop the Docker container # Stop the Docker container
stop: stop:
docker stop $(CONTAINER_NAME) docker stop $(CONTAINER_NAME)

View File

@@ -30,15 +30,15 @@ class AudioStream:
if _input: if _input:
self.paInstance_kwargs['input_device_index'] = _input_device_index self.paInstance_kwargs['input_device_index'] = _input_device_index
else: else:
LOGGER.warning(f"[AudioStream.__init__]:\tInput was not enabled." LOGGER.warning("[AudioStream.__init__]:\tInput was not enabled."
f" Reinitialize with '_input=True'") " Reinitialize with '_input=True'")
if _output_device_index: if _output_device_index:
if _output: if _output:
self.paInstance_kwargs['output_device_index'] = _output_device_index self.paInstance_kwargs['output_device_index'] = _output_device_index
else: else:
LOGGER.warning(f"[AudioStream.__init__]:\tOutput was not enabled." LOGGER.warning("[AudioStream.__init__]:\tOutput was not enabled."
f" Reinitialize with '_output=True'") " Reinitialize with '_output=True'")
if _init_on_startup: if _init_on_startup:
# Init PyAudio instance # Init PyAudio instance
@@ -59,15 +59,15 @@ class AudioStream:
if self.paInstance_kwargs['input']: if self.paInstance_kwargs['input']:
self.paInstance_kwargs['input_device_index'] = _new_input_device_index self.paInstance_kwargs['input_device_index'] = _new_input_device_index
else: else:
LOGGER.warning(f"[AudioStream.init_stream]:\tInput was not enabled when initialized." LOGGER.warning("[AudioStream.init_stream]:\tInput was not enabled when initialized."
f" Reinitialize with '_input=True'") " Reinitialize with '_input=True'")
if _new_output_device_index: if _new_output_device_index:
if self.paInstance_kwargs['output']: if self.paInstance_kwargs['output']:
self.paInstance_kwargs['output_device_index'] = _new_output_device_index self.paInstance_kwargs['output_device_index'] = _new_output_device_index
else: else:
LOGGER.warning(f"[AudioStream.init_stream]:\tOutput was not enabled when initialized." LOGGER.warning("[AudioStream.init_stream]:\tOutput was not enabled when initialized."
f" Reinitialize with '_output=True'") " Reinitialize with '_output=True'")
self.close_if_open() self.close_if_open()
@@ -80,7 +80,7 @@ class AudioStream:
if self.stream.is_active(): if self.stream.is_active():
self.stream.stop_stream() self.stream.stop_stream()
self.stream.close() self.stream.close()
LOGGER.debug(f"[ReopenStream.close_if_open]:\t Stream was open; It was closed.") LOGGER.debug("[ReopenStream.close_if_open]:\t Stream was open; It was closed.")
def list_devices(self, _display_input_devices: bool = True, _display_output_devices: bool = True): def list_devices(self, _display_input_devices: bool = True, _display_output_devices: bool = True):
LOGGER.info('Getting a list of the devices connected') LOGGER.info('Getting a list of the devices connected')
@@ -126,7 +126,7 @@ class NoiseGate(AudioStream):
def run(self) -> None: def run(self) -> None:
global voice_connection global voice_connection
# Start the audio stream # Start the audio stream
LOGGER.debug(f"Starting stream") LOGGER.debug("Starting stream")
self.stream.start_stream() self.stream.start_stream()
# Start the stream to discord # Start the stream to discord
self.core() self.core()
@@ -139,15 +139,15 @@ class NoiseGate(AudioStream):
time.sleep(.2) time.sleep(.2)
if not voice_connection.is_playing(): if not voice_connection.is_playing():
LOGGER.debug(f"Playing stream to discord") LOGGER.debug("Playing stream to discord")
voice_connection.play(self.NGStream, after=self.core) voice_connection.play(self.NGStream, after=self.core)
async def close(self): async def close(self):
LOGGER.debug(f"Closing") LOGGER.debug("Closing")
await voice_connection.disconnect() await voice_connection.disconnect()
if self.stream.is_active: if self.stream.is_active:
self.stream.stop_stream() self.stream.stop_stream()
LOGGER.debug(f"Stopping stream") LOGGER.debug("Stopping stream")
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences

View File

@@ -22,6 +22,8 @@ class DiscordBotManager:
self.token: Optional[str] = None self.token: Optional[str] = None
self.loop = asyncio.get_event_loop() self.loop = asyncio.get_event_loop()
self.lock = asyncio.Lock() self.lock = asyncio.Lock()
self._ready_event = asyncio.Event()
self._voice_ready_event = asyncio.Event()
async def start_bot(self, token: str): async def start_bot(self, token: str):
async with self.lock: async with self.lock:
@@ -36,26 +38,48 @@ class DiscordBotManager:
@self.bot.event @self.bot.event
async def on_ready(): async def on_ready():
LOGGER.info(f'Logged in as {self.bot.user}') LOGGER.info(f'Logged in as {self.bot.user}')
# Set the event when on_ready is called
self._ready_event.set()
@self.bot.event @self.bot.event
async def on_voice_state_update(member, before, after): async def on_voice_state_update(member, before, after):
# Check if the bot was disconnected # Check if the bot was disconnected
if member == self.bot.user and after.channel is None: if member == self.bot.user and after.channel is None:
guild_id = before.channel.guild.id guild_id = before.channel.guild.id
if not self.voice_clients.get(guild_id):
LOGGER.info("Bot has left channel, reconnection ignored.")
return
LOGGER.info(f"Bot was disconnected from channel in guild {guild_id}. Attempting to reconnect...") LOGGER.info(f"Bot was disconnected from channel in guild {guild_id}. Attempting to reconnect...")
try: try:
await leave_voice_channel(guild_id) await self.leave_voice_channel(guild_id)
except Exception as e: except Exception as e:
LOGGER.warning(f"Error leaving voice channel: '{e}'") LOGGER.warning(f"Error leaving voice channel: '{e}'")
# Attempt to reconnect to the channel after a brief pause # Attempt to reconnect to the channel after a brief pause
await asyncio.sleep(2) await asyncio.sleep(2)
await self.join_voice_channel(guild_id, before.channel.id) await self.join_voice_channel(guild_id, before.channel.id)
if member == self.bot.user and before.channel is None and after.channel is not None:
print(f"{member.name} joined voice channel {after.channel.name}")
self._voice_ready_event.set()
# Load Opus for the current CPU # Load Opus for the current CPU
await self.load_opus() await self.load_opus()
# Create the task to run the bot in the background
self.bot_task = self.loop.create_task(self.bot.start(token)) self.bot_task = self.loop.create_task(self.bot.start(token))
# Wait for the on_ready event to be set by the bot task
LOGGER.info("Waiting for bot to become ready...")
try:
await asyncio.wait_for(self._ready_event.wait(), timeout=60.0)
LOGGER.info("Bot is ready, start_bot returning.")
return
except asyncio.TimeoutError:
LOGGER.error("Timeout waiting for bot to become ready. Bot might have failed to start.")
if self.bot_task and not self.bot_task.done():
self.bot_task.cancel()
raise RuntimeError("Bot failed to become ready within timeout.")
async def stop_bot(self): async def stop_bot(self):
async with self.lock: async with self.lock:
if self.bot: if self.bot:
@@ -65,6 +89,7 @@ class DiscordBotManager:
await self.bot_task await self.bot_task
self.bot_task = None self.bot_task = None
self.voice_clients.clear() self.voice_clients.clear()
self._ready_event.clear()
LOGGER.info("Bot has been stopped.") LOGGER.info("Bot has been stopped.")
async def join_voice_channel(self, guild_id: int, channel_id: int, ng_threshold: int = 50, device_id: int = 4): async def join_voice_channel(self, guild_id: int, channel_id: int, ng_threshold: int = 50, device_id: int = 4):
@@ -87,18 +112,27 @@ class DiscordBotManager:
try: try:
voice_client = await channel.connect(timeout=60.0, reconnect=True) voice_client = await channel.connect(timeout=60.0, reconnect=True)
LOGGER.debug(f"Voice Connected.") LOGGER.debug("Voice Connected.")
streamHandler = NoiseGate( streamHandler = NoiseGate(
_input_device_index=device_id, _input_device_index=device_id,
_voice_connection=voice_client, _voice_connection=voice_client,
_noise_gate_threshold=ng_threshold) _noise_gate_threshold=ng_threshold)
streamHandler.run() streamHandler.run()
LOGGER.debug(f"Stream is running.") LOGGER.debug("Stream is running.")
self.voice_clients[guild_id] = voice_client self.voice_clients[guild_id] = voice_client
LOGGER.info(f"Joined guild {guild_id} voice channel {channel_id} and stream is running.") LOGGER.info(f"Joined guild {guild_id} voice channel {channel_id} and stream is running.")
except Exception as e: except Exception as e:
LOGGER.error(f"Failed to connect to voice channel: {e}") LOGGER.error(f"Failed to connect to voice channel: {e}")
LOGGER.info("Waiting for bot to join voice...")
try:
await asyncio.wait_for(self._voice_ready_event.wait(), timeout=60.0)
LOGGER.info("Bot joined voice, returning.")
return
except asyncio.TimeoutError:
LOGGER.error("Timeout waiting for bot to join voice.")
raise RuntimeError("Bot failed to join voice within timeout.")
async def leave_voice_channel(self, guild_id: int): async def leave_voice_channel(self, guild_id: int):
if not self.bot: if not self.bot:
raise RuntimeError("Bot is not running.") raise RuntimeError("Bot is not running.")
@@ -119,21 +153,27 @@ class DiscordBotManager:
if os.name == 'nt': if os.name == 'nt':
if processor == "AMD64": if processor == "AMD64":
opus.load_opus(os.path.join(script_dir, './opus/libopus_amd64.dll')) opus.load_opus(os.path.join(script_dir, './opus/libopus_amd64.dll'))
LOGGER.info(f"Loaded OPUS library for AMD64") LOGGER.info("Loaded OPUS library for AMD64")
return "AMD64" return "AMD64"
else: else:
if processor == "aarch64": if processor == "aarch64":
opus.load_opus(os.path.join(script_dir, './opus/libopus_aarcch64.so')) opus.load_opus(os.path.join(script_dir, './opus/libopus_aarcch64.so'))
LOGGER.info(f"Loaded OPUS library for aarch64") LOGGER.info("Loaded OPUS library for aarch64")
return "aarch64" return "aarch64"
elif processor == "armv7l": elif processor == "armv7l":
opus.load_opus(os.path.join(script_dir, './opus/libopus_armv7l.so')) opus.load_opus(os.path.join(script_dir, './opus/libopus_armv7l.so'))
LOGGER.info(f"Loaded OPUS library for armv7l") LOGGER.info("Loaded OPUS library for armv7l")
return "armv7l" return "armv7l"
async def set_presence(self, presence: str): async def set_presence(self, system_name: str):
""" Set the presense (activity) of the bot """ """ Set the presence (activity) of the bot """
if not self.bot:
LOGGER.warning("Bot is not running, cannot set presence.")
return
try: try:
await self.bot.change_presence(activity=Activity(type=ActivityType.listening, name=presence)) activity = Activity(type=ActivityType.listening, name=system_name)
await self.bot.change_presence(activity=activity)
LOGGER.info(f"Bot presence set to 'Listening to {system_name}'")
except Exception as pe: except Exception as pe:
LOGGER.error(f"Unable to set presence: '{pe}'") LOGGER.error(f"Unable to set presence: '{pe}'")

View File

@@ -0,0 +1,70 @@
import csv
import json
from models import TalkgroupTag
from typing import List
from internal.logger import create_logger
LOGGER = create_logger(__name__)
def save_talkgroup_tags(talkgroup_tags: List[TalkgroupTag]) -> None:
"""
Writes a list of tags to the tags file.
Args:
talkgroup_tags (List[TalkgroupTag]): The list of TalkgroupTag instances.
"""
with open("/configs/active.cfg.tags.tsv", 'w', newline='', encoding='utf-8') as file:
writer = csv.writer(file, delimiter='\t', lineterminator='\n')
# Write rows
for tag in talkgroup_tags:
writer.writerow([tag.talkgroup, tag.tagDec])
def save_whitelist(talkgroup_tags: List[int]) -> None:
"""
Writes a list of talkgroups to the whitelists file.
Args:
talkgroup_tags (List[int]): The list of decimals to whitelist.
"""
with open("/configs/active.cfg.whitelist.tsv", 'w', newline='', encoding='utf-8') as file:
writer = csv.writer(file, delimiter='\t', lineterminator='\n')
# Write rows
for tag in talkgroup_tags:
writer.writerow([tag])
def del_none_in_dict(d):
"""
Delete keys with the value ``None`` in a dictionary, recursively.
This alters the input so you may wish to ``copy`` the dict first.
"""
for key, value in list(d.items()):
LOGGER.info(f"Key: '{key}'\nValue: '{value}'")
if value is None:
del d[key]
elif isinstance(value, dict):
del_none_in_dict(value)
elif isinstance(value, list):
for iterative_value in value:
del_none_in_dict(iterative_value)
return d # For convenience
def get_current_system_from_config() -> str:
# Get the current config
with open('/configs/active.cfg.json', 'r') as f:
json_data = f.read()
if isinstance(json_data, str):
try:
data = json.loads(json_data)
except json.JSONDecodeError:
return None
elif isinstance(json_data, dict):
data = json_data
else:
return None
if "channels" in data and isinstance(data["channels"], list) and len(data["channels"]) > 0:
first_channel = data["channels"][0]
if "name" in first_channel:
return first_channel["name"]
return None

View File

@@ -1,13 +1,9 @@
import asyncio from fastapi import FastAPI
import discord
from discord.ext import commands
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional, Dict
import routers.op25_controller as op25_controller import routers.op25_controller as op25_controller
import routers.pulse as pulse import routers.pulse as pulse
import routers.bot as bot import routers.bot as bot
from internal.logger import create_logger from internal.logger import create_logger
from internal.bot_manager import DiscordBotManager
# Initialize logging # Initialize logging
LOGGER = create_logger(__name__) LOGGER = create_logger(__name__)
@@ -15,6 +11,9 @@ LOGGER = create_logger(__name__)
# Define FastAPI app # Define FastAPI app
app = FastAPI() app = FastAPI()
app.include_router(op25_controller.router, prefix="/op25") # Initialize Discord Bot Manager
bot_manager_instance = DiscordBotManager()
app.include_router(op25_controller.create_op25_router(bot_manager=bot_manager_instance), prefix="/op25")
app.include_router(pulse.router, prefix="/pulse") app.include_router(pulse.router, prefix="/pulse")
app.include_router(bot.router, prefix="/bot") app.include_router(bot.create_bot_router(bot_manager=bot_manager_instance), prefix="/bot")

View File

@@ -1,15 +1,18 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Optional from typing import List, Optional, Union
from enum import Enum from enum import Enum
class BotConfig(BaseModel): class BotConfig(BaseModel):
token: str token: str
class VoiceChannelRequest(BaseModel): class VoiceChannelJoinRequest(BaseModel):
guild_id: int guild_id: int
channel_id: int channel_id: int
class VoiceChannelLeaveRequest(BaseModel):
guild_id: int
class DecodeMode(str, Enum): class DecodeMode(str, Enum):
P25 = "P25" P25 = "P25"
DMR = "DMR" DMR = "DMR"
@@ -22,7 +25,7 @@ class TalkgroupTag(BaseModel):
class ConfigGenerator(BaseModel): class ConfigGenerator(BaseModel):
type: DecodeMode type: DecodeMode
systemName: str systemName: str
channels: List[str] channels: List[Union[int, str]]
tags: Optional[List[TalkgroupTag]] tags: Optional[List[TalkgroupTag]]
whitelist: Optional[List[int]] whitelist: Optional[List[int]]

View File

@@ -1,21 +1,14 @@
import asyncio
import discord
from discord.ext import commands
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from models import BotConfig, VoiceChannelJoinRequest, VoiceChannelLeaveRequest
from typing import Optional, Dict
from models import BotConfig, VoiceChannelRequest
from internal.bot_manager import DiscordBotManager from internal.bot_manager import DiscordBotManager
from internal.logger import create_logger from internal.logger import create_logger
LOGGER = create_logger(__name__) LOGGER = create_logger(__name__)
# Define FastAPI app # Function to create router
def create_bot_router(bot_manager: DiscordBotManager):
router = APIRouter() router = APIRouter()
# Initialize Discord Bot Manager
bot_manager = DiscordBotManager()
# API Endpoints # API Endpoints
@router.post("/start_bot") @router.post("/start_bot")
async def start_bot(config: BotConfig): async def start_bot(config: BotConfig):
@@ -36,7 +29,7 @@ async def stop_bot():
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@router.post("/join_voice") @router.post("/join_voice")
async def join_voice_channel(request: VoiceChannelRequest): async def join_voice_channel(request: VoiceChannelJoinRequest):
try: try:
await bot_manager.join_voice_channel(request.guild_id, request.channel_id) await bot_manager.join_voice_channel(request.guild_id, request.channel_id)
return {"status": f"Joined guild {request.guild_id} voice channel {request.channel_id}."} return {"status": f"Joined guild {request.guild_id} voice channel {request.channel_id}."}
@@ -45,7 +38,7 @@ async def join_voice_channel(request: VoiceChannelRequest):
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@router.post("/leave_voice") @router.post("/leave_voice")
async def leave_voice_channel(request: VoiceChannelRequest): async def leave_voice_channel(request: VoiceChannelLeaveRequest):
try: try:
await bot_manager.leave_voice_channel(request.guild_id) await bot_manager.leave_voice_channel(request.guild_id)
return {"status": f"Left guild {request.guild_id} voice channel."} return {"status": f"Left guild {request.guild_id} voice channel."}
@@ -57,6 +50,9 @@ async def leave_voice_channel(request: VoiceChannelRequest):
async def get_status(): async def get_status():
status = { status = {
"bot_running": bot_manager.bot is not None and not bot_manager.bot.is_closed(), "bot_running": bot_manager.bot is not None and not bot_manager.bot.is_closed(),
"connected_guilds": list(bot_manager.voice_clients.keys()) "connected_guilds": list(bot_manager.voice_clients.keys()),
"active_token": bot_manager.token
} }
return status return status
return router

View File

@@ -1,20 +1,22 @@
from fastapi import HTTPException, APIRouter from fastapi import HTTPException, APIRouter
from pydantic import BaseModel
import subprocess import subprocess
import os import os
import signal import signal
import json import json
import csv from models import ConfigGenerator, DecodeMode, ChannelConfig, DeviceConfig, TrunkingConfig, TrunkingChannelConfig, AudioConfig, TerminalConfig
from models import *
from internal.logger import create_logger from internal.logger import create_logger
from internal.bot_manager import DiscordBotManager
from internal.op25_config_utls import save_talkgroup_tags, save_whitelist, del_none_in_dict, get_current_system_from_config
router = APIRouter()
LOGGER = create_logger(__name__) LOGGER = create_logger(__name__)
op25_process = None op25_process = None
OP25_PATH = "/op25/op25/gr-op25_repeater/apps/" OP25_PATH = "/op25/op25/gr-op25_repeater/apps/"
OP25_SCRIPT = "run_multi-rx_service.sh" OP25_SCRIPT = "run_multi-rx_service.sh"
def create_op25_router(bot_manager: DiscordBotManager):
router = APIRouter()
@router.post("/start") @router.post("/start")
async def start_op25(): async def start_op25():
global op25_process global op25_process
@@ -105,49 +107,20 @@ async def generate_config(generator: ConfigGenerator):
with open('/configs/active.cfg.json', 'w') as f: with open('/configs/active.cfg.json', 'w') as f:
json.dump(del_none_in_dict(config_dict), f, indent=2) json.dump(del_none_in_dict(config_dict), f, indent=2)
# Set the presence of the bot (if it's online)
await bot_manager.set_presence(generator.systemName)
return {"message": "Config exported to '/configs/active.cfg.json'"} return {"message": "Config exported to '/configs/active.cfg.json'"}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
def save_talkgroup_tags(talkgroup_tags: List[TalkgroupTag]) -> None: @router.post("/update-presence")
""" async def update_presence():
Writes a list of tags to the tags file. current_system = get_current_system_from_config()
if not current_system:
raise HTTPException(status_code=500, detail="Unable to get current system.")
Args: await bot_manager.set_presence(current_system)
talkgroup_tags (List[TalkgroupTag]): The list of TalkgroupTag instances. return current_system
"""
with open("/configs/active.cfg.tags.tsv", 'w', newline='', encoding='utf-8') as file:
writer = csv.writer(file, delimiter='\t', lineterminator='\n')
# Write rows
for tag in talkgroup_tags:
writer.writerow([tag.talkgroup, tag.tagDec])
def save_whitelist(talkgroup_tags: List[int]) -> None: return router
"""
Writes a list of talkgroups to the whitelists file.
Args:
talkgroup_tags (List[int]): The list of decimals to whitelist.
"""
with open("/configs/active.cfg.whitelist.tsv", 'w', newline='', encoding='utf-8') as file:
writer = csv.writer(file, delimiter='\t', lineterminator='\n')
# Write rows
for tag in talkgroup_tags:
writer.writerow([tag])
def del_none_in_dict(d):
"""
Delete keys with the value ``None`` in a dictionary, recursively.
This alters the input so you may wish to ``copy`` the dict first.
"""
for key, value in list(d.items()):
LOGGER.info(f"Key: '{key}'\nValue: '{value}'")
if value is None:
del d[key]
elif isinstance(value, dict):
del_none_in_dict(value)
elif isinstance(value, list):
for iterative_value in value:
del_none_in_dict(iterative_value)
return d # For convenience

15
op25-liq.service Normal file
View File

@@ -0,0 +1,15 @@
[Unit]
Description=op25-liq
After=syslog.target network.target nss-lookup.target network-online.target
Requires=network-online.target
[Service]
User=1000
Group=1000
WorkingDirectory=/op25/op25/gr-op25_repeater/apps
ExecStart=/usr/bin/liquidsoap op25.liq
RestartSec=5
Restart=on-failure
[Install]
WantedBy=multi-user.target

View File

@@ -5,4 +5,3 @@ uvicorn
fastapi fastapi
pyaudio pyaudio
argparse argparse
pyaudio