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:
release-image:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
env:
DOCKER_LATEST: nightly
DOCKER_LATEST: stable
CONTAINER_NAME: drb-client-discord-bot
steps:
- name: Checkout
@@ -52,7 +50,7 @@ jobs:
context: .
file: ./Dockerfile
platforms: |
linux/arm4
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 }}

3
.gitignore vendored
View File

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

View File

@@ -61,7 +61,7 @@ VOLUME ["/configs"]
WORKDIR /app
# 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 ./app /app

View File

@@ -23,6 +23,16 @@ run: build
--network=host \
$(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:
docker stop $(CONTAINER_NAME)

View File

@@ -30,15 +30,15 @@ class AudioStream:
if _input:
self.paInstance_kwargs['input_device_index'] = _input_device_index
else:
LOGGER.warning(f"[AudioStream.__init__]:\tInput was not enabled."
f" Reinitialize with '_input=True'")
LOGGER.warning("[AudioStream.__init__]:\tInput was not enabled."
" Reinitialize with '_input=True'")
if _output_device_index:
if _output:
self.paInstance_kwargs['output_device_index'] = _output_device_index
else:
LOGGER.warning(f"[AudioStream.__init__]:\tOutput was not enabled."
f" Reinitialize with '_output=True'")
LOGGER.warning("[AudioStream.__init__]:\tOutput was not enabled."
" Reinitialize with '_output=True'")
if _init_on_startup:
# Init PyAudio instance
@@ -59,15 +59,15 @@ class AudioStream:
if self.paInstance_kwargs['input']:
self.paInstance_kwargs['input_device_index'] = _new_input_device_index
else:
LOGGER.warning(f"[AudioStream.init_stream]:\tInput was not enabled when initialized."
f" Reinitialize with '_input=True'")
LOGGER.warning("[AudioStream.init_stream]:\tInput was not enabled when initialized."
" Reinitialize with '_input=True'")
if _new_output_device_index:
if self.paInstance_kwargs['output']:
self.paInstance_kwargs['output_device_index'] = _new_output_device_index
else:
LOGGER.warning(f"[AudioStream.init_stream]:\tOutput was not enabled when initialized."
f" Reinitialize with '_output=True'")
LOGGER.warning("[AudioStream.init_stream]:\tOutput was not enabled when initialized."
" Reinitialize with '_output=True'")
self.close_if_open()
@@ -80,7 +80,7 @@ class AudioStream:
if self.stream.is_active():
self.stream.stop_stream()
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):
LOGGER.info('Getting a list of the devices connected')
@@ -126,7 +126,7 @@ class NoiseGate(AudioStream):
def run(self) -> None:
global voice_connection
# Start the audio stream
LOGGER.debug(f"Starting stream")
LOGGER.debug("Starting stream")
self.stream.start_stream()
# Start the stream to discord
self.core()
@@ -139,15 +139,15 @@ class NoiseGate(AudioStream):
time.sleep(.2)
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)
async def close(self):
LOGGER.debug(f"Closing")
LOGGER.debug("Closing")
await voice_connection.disconnect()
if self.stream.is_active:
self.stream.stop_stream()
LOGGER.debug(f"Stopping stream")
LOGGER.debug("Stopping stream")
# noinspection PyUnresolvedReferences
@@ -155,7 +155,7 @@ class NoiseGateStream(discord.AudioSource):
def __init__(self, _stream):
super(NoiseGateStream, self).__init__()
self.stream = _stream # The actual audio stream object
self.NG_fadeout = 240/20 # Fadeout value used to hold the noisegate after de-triggering
self.NG_fadeout = 240 / 20 # Fadeout value used to hold the noisegate after de-triggering
self.NG_fadeout_count = 0 # A count set when the noisegate is triggered and was de-triggered
self.process_set_count = 0 # Counts how many processes have been made

View File

@@ -1,7 +1,7 @@
import asyncio
import platform
import os
from discord import VoiceClient, VoiceChannel, opus, Activity, ActivityType, Intents
from discord import VoiceClient, VoiceChannel, opus, Activity, ActivityType, Intents
from discord.ext import commands
from typing import Optional, Dict
from internal.NoiseGatev2 import NoiseGate
@@ -22,6 +22,8 @@ class DiscordBotManager:
self.token: Optional[str] = None
self.loop = asyncio.get_event_loop()
self.lock = asyncio.Lock()
self._ready_event = asyncio.Event()
self._voice_ready_event = asyncio.Event()
async def start_bot(self, token: str):
async with self.lock:
@@ -36,26 +38,48 @@ class DiscordBotManager:
@self.bot.event
async def on_ready():
LOGGER.info(f'Logged in as {self.bot.user}')
# Set the event when on_ready is called
self._ready_event.set()
@self.bot.event
async def on_voice_state_update(member, before, after):
# Check if the bot was disconnected
if member == self.bot.user and after.channel is None:
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...")
try:
await leave_voice_channel(guild_id)
await self.leave_voice_channel(guild_id)
except Exception as e:
LOGGER.warning(f"Error leaving voice channel: '{e}'")
# Attempt to reconnect to the channel after a brief pause
await asyncio.sleep(2)
await self.join_voice_channel(guild_id, before.channel.id)
# Load Opus for the current CPU
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
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))
# 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 with self.lock:
if self.bot:
@@ -65,6 +89,7 @@ class DiscordBotManager:
await self.bot_task
self.bot_task = None
self.voice_clients.clear()
self._ready_event.clear()
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):
@@ -84,21 +109,30 @@ class DiscordBotManager:
if guild_id in self.voice_clients:
raise RuntimeError("Already connected to this guild's voice channel.")
try:
voice_client = await channel.connect(timeout=60.0, reconnect=True)
LOGGER.debug(f"Voice Connected.")
LOGGER.debug("Voice Connected.")
streamHandler = NoiseGate(
_input_device_index=device_id,
_voice_connection=voice_client,
_noise_gate_threshold=ng_threshold)
streamHandler.run()
LOGGER.debug(f"Stream is running.")
LOGGER.debug("Stream is running.")
self.voice_clients[guild_id] = voice_client
LOGGER.info(f"Joined guild {guild_id} voice channel {channel_id} and stream is running.")
except Exception as 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):
if not self.bot:
raise RuntimeError("Bot is not running.")
@@ -117,23 +151,29 @@ class DiscordBotManager:
script_dir = os.path.dirname(os.path.abspath(__file__))
LOGGER.debug("Processor: ", processor)
if os.name == 'nt':
if processor == "AMD64":
if processor == "AMD64":
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"
else:
if processor == "aarch64":
if processor == "aarch64":
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"
elif processor == "armv7l":
elif processor == "armv7l":
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"
async def set_presence(self, presence: str):
""" Set the presense (activity) of the bot """
async def set_presence(self, system_name: str):
""" Set the presence (activity) of the bot """
if not self.bot:
LOGGER.warning("Bot is not running, cannot set presence.")
return
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:
LOGGER.error(f"Unable to set presence: '{pe}'")
LOGGER.error(f"Unable to set presence: '{pe}'")

View File

@@ -4,52 +4,52 @@ from logging.handlers import RotatingFileHandler
def create_logger(name, level=logging.DEBUG, max_bytes=10485760, backup_count=2):
"""
Creates a logger with a console and rotating file handlers for both debug and info log levels.
Args:
name (str): The name for the logger.
level (int): The logging level for the logger. Defaults to logging.DEBUG.
max_bytes (int): Maximum size of the log file in bytes before it gets rotated. Defaults to 10 MB.
backup_count (int): Number of backup files to keep. Defaults to 2.
Returns:
logging.Logger: Configured logger.
"""
# Set the log file paths
debug_log_file = "./client.debug.log"
info_log_file = "./client.log"
# Create a logger
logger = logging.getLogger(name)
logger.setLevel(level)
# Check if the logger already has handlers to avoid duplicate logs
if not logger.hasHandlers():
# Create console handler
console_handler = logging.StreamHandler()
console_handler.setLevel(level)
# Create rotating file handler for debug level
debug_file_handler = RotatingFileHandler(debug_log_file, maxBytes=max_bytes, backupCount=backup_count)
debug_file_handler.setLevel(logging.DEBUG)
# Create rotating file handler for info level
info_file_handler = RotatingFileHandler(info_log_file, maxBytes=max_bytes, backupCount=backup_count)
info_file_handler.setLevel(logging.INFO)
# Create formatter and add it to the handlers
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
console_handler.setFormatter(formatter)
debug_file_handler.setFormatter(formatter)
info_file_handler.setFormatter(formatter)
# Add the handlers to the logger
logger.addHandler(console_handler)
logger.addHandler(debug_file_handler)
logger.addHandler(info_file_handler)
return logger
# Example usage:
# logger = create_logger('my_logger')
# logger.debug('This is a debug message')
# logger.info('This is an info message')
# logger.info('This is an info message')

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
import discord
from discord.ext import commands
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional, Dict
from fastapi import FastAPI
import routers.op25_controller as op25_controller
import routers.pulse as pulse
import routers.bot as bot
from internal.logger import create_logger
from internal.bot_manager import DiscordBotManager
# Initialize logging
LOGGER = create_logger(__name__)
@@ -15,6 +11,9 @@ LOGGER = create_logger(__name__)
# Define FastAPI app
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(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 typing import List, Optional
from typing import List, Optional, Union
from enum import Enum
class BotConfig(BaseModel):
token: str
class VoiceChannelRequest(BaseModel):
class VoiceChannelJoinRequest(BaseModel):
guild_id: int
channel_id: int
class VoiceChannelLeaveRequest(BaseModel):
guild_id: int
class DecodeMode(str, Enum):
P25 = "P25"
DMR = "DMR"
@@ -22,7 +25,7 @@ class TalkgroupTag(BaseModel):
class ConfigGenerator(BaseModel):
type: DecodeMode
systemName: str
channels: List[str]
channels: List[Union[int, str]]
tags: Optional[List[TalkgroupTag]]
whitelist: Optional[List[int]]

View File

@@ -1,62 +1,58 @@
import asyncio
import discord
from discord.ext import commands
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional, Dict
from models import BotConfig, VoiceChannelRequest
from models import BotConfig, VoiceChannelJoinRequest, VoiceChannelLeaveRequest
from internal.bot_manager import DiscordBotManager
from internal.logger import create_logger
LOGGER = create_logger(__name__)
# Define FastAPI app
router = APIRouter()
# Function to create router
def create_bot_router(bot_manager: DiscordBotManager):
router = APIRouter()
# Initialize Discord Bot Manager
bot_manager = DiscordBotManager()
# API Endpoints
@router.post("/start_bot")
async def start_bot(config: BotConfig):
try:
await bot_manager.start_bot(config.token)
return {"status": "Bot started successfully."}
except Exception as e:
LOGGER.error(f"Error starting bot: {e}")
raise HTTPException(status_code=400, detail=str(e))
# API Endpoints
@router.post("/start_bot")
async def start_bot(config: BotConfig):
try:
await bot_manager.start_bot(config.token)
return {"status": "Bot started successfully."}
except Exception as e:
LOGGER.error(f"Error starting bot: {e}")
raise HTTPException(status_code=400, detail=str(e))
@router.post("/stop_bot")
async def stop_bot():
try:
await bot_manager.stop_bot()
return {"status": "Bot stopped successfully."}
except Exception as e:
LOGGER.error(f"Error stopping bot: {e}")
raise HTTPException(status_code=400, detail=str(e))
@router.post("/stop_bot")
async def stop_bot():
try:
await bot_manager.stop_bot()
return {"status": "Bot stopped successfully."}
except Exception as e:
LOGGER.error(f"Error stopping bot: {e}")
raise HTTPException(status_code=400, detail=str(e))
@router.post("/join_voice")
async def join_voice_channel(request: VoiceChannelJoinRequest):
try:
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}."}
except Exception as e:
LOGGER.error(f"Error joining voice channel: {e}")
raise HTTPException(status_code=400, detail=str(e))
@router.post("/join_voice")
async def join_voice_channel(request: VoiceChannelRequest):
try:
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}."}
except Exception as e:
LOGGER.error(f"Error joining voice channel: {e}")
raise HTTPException(status_code=400, detail=str(e))
@router.post("/leave_voice")
async def leave_voice_channel(request: VoiceChannelLeaveRequest):
try:
await bot_manager.leave_voice_channel(request.guild_id)
return {"status": f"Left guild {request.guild_id} voice channel."}
except Exception as e:
LOGGER.error(f"Error leaving voice channel: {e}")
raise HTTPException(status_code=400, detail=str(e))
@router.post("/leave_voice")
async def leave_voice_channel(request: VoiceChannelRequest):
try:
await bot_manager.leave_voice_channel(request.guild_id)
return {"status": f"Left guild {request.guild_id} voice channel."}
except Exception as e:
LOGGER.error(f"Error leaving voice channel: {e}")
raise HTTPException(status_code=400, detail=str(e))
@router.get("/status")
async def get_status():
status = {
"bot_running": bot_manager.bot is not None and not bot_manager.bot.is_closed(),
"connected_guilds": list(bot_manager.voice_clients.keys()),
"active_token": bot_manager.token
}
return status
@router.get("/status")
async def get_status():
status = {
"bot_running": bot_manager.bot is not None and not bot_manager.bot.is_closed(),
"connected_guilds": list(bot_manager.voice_clients.keys())
}
return status
return router

View File

@@ -1,153 +1,126 @@
from fastapi import HTTPException, APIRouter
from pydantic import BaseModel
import subprocess
import os
import signal
import json
import csv
from models import *
from models import ConfigGenerator, DecodeMode, ChannelConfig, DeviceConfig, TrunkingConfig, TrunkingChannelConfig, AudioConfig, TerminalConfig
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__)
op25_process = None
OP25_PATH = "/op25/op25/gr-op25_repeater/apps/"
OP25_SCRIPT = "run_multi-rx_service.sh"
@router.post("/start")
async def start_op25():
global op25_process
if op25_process is None:
try:
op25_process = subprocess.Popen(os.path.join(OP25_PATH, OP25_SCRIPT), shell=True, preexec_fn=os.setsid, cwd=OP25_PATH)
LOGGER.debug(op25_process)
return {"status": "OP25 started"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
else:
return {"status": "OP25 already running"}
@router.post("/stop")
async def stop_op25():
global op25_process
if op25_process is not None:
try:
os.killpg(os.getpgid(op25_process.pid), signal.SIGTERM)
op25_process = None
return {"status": "OP25 stopped"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
else:
return {"status": "OP25 is not running"}
@router.get("/status")
async def get_status():
return {"status": "running" if op25_process else "stopped"}
@router.post("/generate-config")
async def generate_config(generator: ConfigGenerator):
try:
if generator.type == DecodeMode.P25:
channels = [ChannelConfig(
name=generator.systemName,
trunking_sysname=generator.systemName,
enable_analog="off",
demod_type="cqpsk",
cqpsk_tracking=True,
filter_type="rc"
)]
devices = [DeviceConfig()]
save_talkgroup_tags(generator.tags)
save_whitelist(generator.whitelist)
trunking = TrunkingConfig(
module="tk_p25.py",
chans=[TrunkingChannelConfig(
sysname=generator.systemName,
control_channel_list=','.join(generator.channels),
tagsFile="/configs/active.cfg.tags.tsv",
whitelist="/configs/active.cfg.whitelist.tsv"
)]
)
audio = AudioConfig()
terminal = TerminalConfig()
config_dict = {
"channels": [channel.dict() for channel in channels],
"devices": [device.dict() for device in devices],
"trunking": trunking.dict(),
"audio": audio.dict(),
"terminal": terminal.dict()
}
elif generator.type == DecodeMode.ANALOG:
generator = generator.config
channels = [ChannelConfig(
channelName=generator.systemName,
enableAnalog="on",
demodType="fsk4",
frequency=generator.frequency,
filterType="widepulse",
nbfmSquelch=generator.nbfmSquelch
)]
devices = [DeviceConfig(gain="LNA:32")]
config_dict = {
"channels": [channel.dict() for channel in channels],
"devices": [device.dict() for device in devices]
}
def create_op25_router(bot_manager: DiscordBotManager):
router = APIRouter()
@router.post("/start")
async def start_op25():
global op25_process
if op25_process is None:
try:
op25_process = subprocess.Popen(os.path.join(OP25_PATH, OP25_SCRIPT), shell=True, preexec_fn=os.setsid, cwd=OP25_PATH)
LOGGER.debug(op25_process)
return {"status": "OP25 started"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
else:
raise HTTPException(status_code=400, detail="Invalid configuration type. Must be 'p25' or 'nbfm'.")
return {"status": "OP25 already running"}
with open('/configs/active.cfg.json', 'w') as f:
json.dump(del_none_in_dict(config_dict), f, indent=2)
@router.post("/stop")
async def stop_op25():
global op25_process
if op25_process is not None:
try:
os.killpg(os.getpgid(op25_process.pid), signal.SIGTERM)
op25_process = None
return {"status": "OP25 stopped"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
else:
return {"status": "OP25 is not running"}
return {"message": "Config exported to '/configs/active.cfg.json'"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/status")
async def get_status():
return {"status": "running" if op25_process else "stopped"}
def save_talkgroup_tags(talkgroup_tags: List[TalkgroupTag]) -> None:
"""
Writes a list of tags to the tags file.
@router.post("/generate-config")
async def generate_config(generator: ConfigGenerator):
try:
if generator.type == DecodeMode.P25:
channels = [ChannelConfig(
name=generator.systemName,
trunking_sysname=generator.systemName,
enable_analog="off",
demod_type="cqpsk",
cqpsk_tracking=True,
filter_type="rc"
)]
devices = [DeviceConfig()]
save_talkgroup_tags(generator.tags)
save_whitelist(generator.whitelist)
trunking = TrunkingConfig(
module="tk_p25.py",
chans=[TrunkingChannelConfig(
sysname=generator.systemName,
control_channel_list=','.join(generator.channels),
tagsFile="/configs/active.cfg.tags.tsv",
whitelist="/configs/active.cfg.whitelist.tsv"
)]
)
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])
audio = AudioConfig()
def save_whitelist(talkgroup_tags: List[int]) -> None:
"""
Writes a list of talkgroups to the whitelists file.
terminal = TerminalConfig()
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])
config_dict = {
"channels": [channel.dict() for channel in channels],
"devices": [device.dict() for device in devices],
"trunking": trunking.dict(),
"audio": audio.dict(),
"terminal": terminal.dict()
}
def del_none_in_dict(d):
"""
Delete keys with the value ``None`` in a dictionary, recursively.
elif generator.type == DecodeMode.ANALOG:
generator = generator.config
channels = [ChannelConfig(
channelName=generator.systemName,
enableAnalog="on",
demodType="fsk4",
frequency=generator.frequency,
filterType="widepulse",
nbfmSquelch=generator.nbfmSquelch
)]
devices = [DeviceConfig(gain="LNA:32")]
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
config_dict = {
"channels": [channel.dict() for channel in channels],
"devices": [device.dict() for device in devices]
}
else:
raise HTTPException(status_code=400, detail="Invalid configuration type. Must be 'p25' or 'nbfm'.")
with open('/configs/active.cfg.json', 'w') as f:
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'"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/update-presence")
async def update_presence():
current_system = get_current_system_from_config()
if not current_system:
raise HTTPException(status_code=500, detail="Unable to get current system.")
await bot_manager.set_presence(current_system)
return current_system
return router

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

@@ -4,5 +4,4 @@ numpy==1.24.3
uvicorn
fastapi
pyaudio
argparse
pyaudio
argparse