45 Commits

Author SHA1 Message Date
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
71af2c83d9 Merge remote-tracking branch 'origin/master' into fix-disconnection-bug
Some checks failed
Lint / lint (pull_request) Failing after 20s
2025-03-01 01:31:29 -05:00
59ee866ac9 Refactored to better split everything up 2025-03-01 01:31:17 -05:00
Logan Cusano
50b11efd80 Retry build with new buildx release and dockerfile changes
Some checks failed
Lint / lint (push) Failing after 20s
release-tag / release-image (push) Failing after 20s
2025-03-01 01:02:53 -05:00
Logan Cusano
f1de077b72 Merge remote-tracking branch 'origin/master' into fix-disconnection-bug
All checks were successful
Lint / lint (pull_request) Successful in 38s
2025-02-23 21:42:54 -05:00
Logan Cusano
7362715a21 Uniform apt-get
All checks were successful
Lint / lint (push) Successful in 32s
2025-02-23 21:33:06 -05:00
Logan Cusano
74e66eb796 Linting
All checks were successful
Lint / lint (pull_request) Successful in 37s
2025-02-23 20:37:40 -05:00
Logan Cusano
925243b53c Merge remote-tracking branch 'origin/master' into fix-disconnection-bug
Some checks failed
Lint / lint (pull_request) Failing after 43s
2025-02-23 20:35:56 -05:00
Logan Cusano
acb72eff03 Reset build step and disabled for now
All checks were successful
Lint / lint (push) Successful in 19s
2025-02-23 20:33:36 -05:00
5434941f3d Fix logic to disconnect then try to reconnect 2025-02-23 20:31:16 -05:00
Logan Cusano
d41ed60ee6 Update dockerfile to speed up debugging
Some checks failed
release-tag / release-image (push) Failing after 1m42s
Lint / lint (push) Successful in 39s
2025-02-23 17:42:26 -05:00
Logan Cusano
3ded857456 testing just arm64 build
Some checks failed
release-tag / release-image (push) Failing after 18m48s
Lint / lint (push) Failing after 14s
2025-02-23 16:36:30 -05:00
Logan Cusano
78432c07a0 Remove arm64 for testing
All checks were successful
Lint / lint (push) Successful in 6s
release-tag / release-image (push) Successful in 12m0s
2025-02-23 16:06:11 -05:00
Logan Cusano
f20b6cc0ed Add validation and remove armv7 (for testing)(
Some checks failed
Lint / lint (push) Successful in 10s
release-tag / release-image (push) Failing after 19m41s
2025-02-23 15:27:38 -05:00
Logan Cusano
c872a27017 Update build workfloow
Some checks failed
release-tag / release-image (push) Failing after 21m8s
Lint / lint (push) Successful in 31s
2025-02-23 13:47:36 -05:00
Logan Cusano
af6c9fb763 Undo version bump
Some checks failed
release-tag / release-image (push) Failing after 19m22s
Lint / lint (push) Failing after 39s
2025-02-23 12:24:45 -05:00
Logan Cusano
3dbbdaeec2 Update dockerfile
Some checks failed
Lint / lint (push) Successful in 29s
release-tag / release-image (push) Failing after 54s
- Bump ubuntu version
- Switch to apt from apt-get
- Add extra install for apt-transport-https
2025-02-23 12:20:06 -05:00
55ee80ce22 Merge pull request 'Correctly implement actions' (#3) from implement-actions into master
Some checks failed
Lint / lint (push) Successful in 12s
release-tag / release-image (push) Failing after 19m3s
Reviewed-on: #3
2025-02-23 11:31:30 -05:00
8f0df2e3e1 Linting
All checks were successful
Lint / lint (pull_request) Successful in 1m15s
2025-02-22 22:51:01 -05:00
1de9e9ce1d remove test for another time
Some checks failed
Lint / lint (pull_request) Failing after 22s
2025-02-22 22:43:07 -05:00
3c99d05708 Bumb versions in linting
Some checks failed
Lint / lint (pull_request) Failing after 2m1s
Test / test (pull_request) Failing after 29s
2025-02-22 22:40:19 -05:00
57e93eaa81 Fix actions
Some checks failed
Test / test (pull_request) Failing after 56s
Lint / lint (pull_request) Failing after 47s
- Fix branch names
- Update PRs to run on any branch
2025-02-22 22:31:39 -05:00
597c73f45e Update yaml on something that will run on PR 2025-02-22 22:26:17 -05:00
7c9b9ba446 Use 'yaml' instead of 'yml' 2025-02-22 22:25:34 -05:00
d753f8aebb Merge remote-tracking branch 'origin/master' into implement-actions 2025-02-22 22:23:33 -05:00
f14fe1f789 Move the action configs to correct dir 2025-02-22 22:22:58 -05:00
0fb1a155b5 add logging and reconnection logic 2025-02-22 02:52:02 -05:00
21 changed files with 553 additions and 330 deletions

View File

@@ -3,17 +3,14 @@ name: release-tag
on: on:
push: push:
branches: branches:
- main - master
jobs: jobs:
release-image: release-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
env: env:
DOCKER_ORG: teacup
DOCKER_LATEST: nightly DOCKER_LATEST: nightly
RUNNER_TOOL_CACHE: /toolcache CONTAINER_NAME: drb-client-discord-bot
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -33,8 +30,8 @@ jobs:
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.vpn.cusano.net # replace it with your local IP registry: git.vpn.cusano.net # replace it with your local IP
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.GIT_REPO_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.GIT_REPO_PASSWORD }}
- name: Get Meta - name: Get Meta
id: meta id: meta
@@ -42,15 +39,19 @@ jobs:
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $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 - name: Build and push
uses: docker/build-push-action@v4 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: | platforms: |
linux/amd64 linux/arm64
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 }}:${{ 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 }}
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ env.DOCKER_LATEST }}

View File

@@ -3,22 +3,22 @@ name: Lint
on: on:
push: push:
branches: branches:
- main - master
pull_request: pull_request:
branches: branches:
- main - "*"
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v2 uses: actions/setup-python@v5
with: with:
python-version: 3.x python-version: '3.13'
- name: Install dependencies - name: Install dependencies
run: | run: |

View File

@@ -1,30 +0,0 @@
name: Test
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.x
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
- name: Run tests
run: |
pytest

3
.gitignore vendored
View File

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

View File

@@ -6,7 +6,8 @@ ENV DEBIAN_FRONTEND=noninteractive
# Install system dependencies # Install system dependencies
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y git \ apt-get install -y --no-install-recommends libc-bin apt-transport-https tzdata && \
apt-get install -y --no-install-recommends git \
curl \ curl \
python3 \ python3 \
python3-pip \ python3-pip \
@@ -25,7 +26,9 @@ RUN apt-get update && \
libportaudio2 \ libportaudio2 \
libpulse-dev \ libpulse-dev \
apulse \ apulse \
ffmpeg ffmpeg \
liquidsoap \
supervisor
# Clone the boatbod op25 repository # Clone the boatbod op25 repository
RUN git clone -b gr310 https://github.com/boatbod/op25 /op25 RUN git clone -b gr310 https://github.com/boatbod/op25 /op25
@@ -36,6 +39,9 @@ WORKDIR /op25
# Run the install script to set up op25 # Run the install script to set up op25
RUN ./install.sh -f RUN ./install.sh -f
# Update the liquid file
COPY op25.liq /op25/op25.liq
# Install Python dependencies # Install Python dependencies
COPY requirements.txt /tmp/requirements.txt COPY requirements.txt /tmp/requirements.txt
RUN pip3 install --no-cache-dir -r /tmp/requirements.txt RUN pip3 install --no-cache-dir -r /tmp/requirements.txt
@@ -59,11 +65,14 @@ VOLUME ["/configs"]
# Set the working directory in the container # Set the working directory in the container
WORKDIR /app WORKDIR /app
# Copy opus first to break up the build time
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
# Copy the pre-built opus libraries # Add Supervisord configuration
COPY ./opus /app/opus COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Run the node script # Modify the ENTRYPOINT to run Supervisord
ENTRYPOINT ["uvicorn", "bot:app", "--host", "0.0.0.0", "--port", "8001", "--reload"] ENTRYPOINT ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

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

@@ -1,157 +0,0 @@
import asyncio
from typing import Optional, Dict
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import discord
from discord.ext import commands
from NoiseGatev2 import NoiseGate
import op25_controller
import pulse
# Define FastAPI app
app = FastAPI()
# Discord Bot Setup
intents = discord.Intents.default()
intents.voice_states = True
intents.guilds = True
# Models for API requests
class BotConfig(BaseModel):
token: str # Discord Bot Token
class VoiceChannelRequest(BaseModel):
guild_id: int
channel_id: int
# Discord Bot Manager
class DiscordBotManager:
def __init__(self):
self.bot: Optional[commands.Bot] = None
self.bot_task: Optional[asyncio.Task] = None
self.voice_clients: Dict[int, discord.VoiceClient] = {}
self.token: Optional[str] = None
self.loop = asyncio.get_event_loop()
self.lock = asyncio.Lock()
async def start_bot(self, token: str):
async with self.lock:
if self.bot and self.bot.is_closed():
raise RuntimeError("Bot is already running.")
if self.bot_task and not self.bot_task.done():
raise RuntimeError("Bot is already running.")
self.token = token
self.bot = commands.Bot(command_prefix="!", intents=intents)
@self.bot.event
async def on_ready():
print(f'Logged in as {self.bot.user}')
# Handle graceful shutdown when all voice connections are closed
@self.bot.event
async def on_voice_state_update(member, before, after):
# Check if all voice clients are disconnected
await asyncio.sleep(1) # Give time for the state to update
if not self.voice_clients:
await self.stop_bot()
# Start the bot in the background
self.bot_task = self.loop.create_task(self.bot.start(token))
async def stop_bot(self):
async with self.lock:
if self.bot:
await self.bot.close()
self.bot = None
if self.bot_task:
await self.bot_task
self.bot_task = None
self.voice_clients.clear()
print("Bot has been stopped.")
async def join_voice_channel(self, guild_id: int, channel_id: int, ng_threshold: int = 50, device_id: int = 4):
if not self.bot:
raise RuntimeError("Bot is not running.")
guild = self.bot.get_guild(guild_id)
if not guild:
raise ValueError("Guild not found.")
channel = guild.get_channel(channel_id)
if not isinstance(channel, discord.VoiceChannel):
raise ValueError("Channel is not a voice channel.")
if guild_id in self.voice_clients:
raise RuntimeError("Already connected to this guild's voice channel.")
voice_client = await channel.connect()
streamHandler = NoiseGate(
_input_device_index=device_id,
_voice_connection=voice_client,
_noise_gate_threshold=ng_threshold)
# Start the audio stream
streamHandler.run()
self.voice_clients[guild_id] = voice_client
print(f"Joined guild {guild_id} voice channel {channel_id}.")
async def leave_voice_channel(self, guild_id: int):
if not self.bot:
raise RuntimeError("Bot is not running.")
voice_client = self.voice_clients.get(guild_id)
if not voice_client:
raise RuntimeError("Not connected to the specified guild's voice channel.")
await voice_client.disconnect()
del self.voice_clients[guild_id]
print(f"Left guild {guild_id} voice channel.")
# Initialize Discord Bot Manager
bot_manager = DiscordBotManager()
# API Endpoints
@app.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:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/stop_bot")
async def stop_bot():
try:
await bot_manager.stop_bot()
return {"status": "Bot stopped successfully."}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.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:
raise HTTPException(status_code=400, detail=str(e))
@app.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:
raise HTTPException(status_code=400, detail=str(e))
@app.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
app.include_router(op25_controller.router, prefix="/op25")
app.include_router(pulse.router, prefix="/pulse")

View File

@@ -1,15 +1,15 @@
import audioop import audioop
import logging
import math import math
import time import time
import pyaudio import pyaudio
import discord import discord
import numpy import numpy
from internal.logger import create_logger
voice_connection = None voice_connection = None
LOGGER = logging.getLogger("Discord_Radio_Bot.NoiseGateV2") LOGGER = create_logger(__name__)
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
@@ -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
@@ -155,7 +155,7 @@ class NoiseGateStream(discord.AudioSource):
def __init__(self, _stream): def __init__(self, _stream):
super(NoiseGateStream, self).__init__() super(NoiseGateStream, self).__init__()
self.stream = _stream # The actual audio stream object 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.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 self.process_set_count = 0 # Counts how many processes have been made

173
app/internal/bot_manager.py Normal file
View File

@@ -0,0 +1,173 @@
import asyncio
import platform
import os
from discord import VoiceClient, VoiceChannel, opus, Activity, ActivityType, Intents
from discord.ext import commands
from typing import Optional, Dict
from internal.NoiseGatev2 import NoiseGate
from internal.logger import create_logger
LOGGER = create_logger(__name__)
# Configure discord intents
intents = Intents.default()
intents.voice_states = True
intents.guilds = True
class DiscordBotManager:
def __init__(self):
self.bot: Optional[commands.Bot] = None
self.bot_task: Optional[asyncio.Task] = None
self.voice_clients: Dict[int, VoiceClient] = {}
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:
if self.bot and not self.bot.is_closed():
raise RuntimeError("Bot is already running.")
if self.bot_task and not self.bot_task.done():
raise RuntimeError("Bot is already running.")
self.token = token
self.bot = commands.Bot(command_prefix="!", intents=intents)
@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 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)
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:
await self.bot.close()
self.bot = None
if self.bot_task:
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):
if not self.bot:
raise RuntimeError("Bot is not running.")
guild = self.bot.get_guild(guild_id)
if not guild:
raise ValueError("Guild not found.")
if not opus.is_loaded():
raise RuntimeError("Opus is not loaded.")
channel = guild.get_channel(channel_id)
if not isinstance(channel, VoiceChannel):
raise ValueError("Channel is not a voice channel.")
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("Voice Connected.")
streamHandler = NoiseGate(
_input_device_index=device_id,
_voice_connection=voice_client,
_noise_gate_threshold=ng_threshold)
streamHandler.run()
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.")
voice_client = self.voice_clients.get(guild_id)
if not voice_client:
raise RuntimeError("Not connected to the specified guild's voice channel.")
await voice_client.disconnect()
del self.voice_clients[guild_id]
LOGGER.info(f"Left guild {guild_id} voice channel.")
async def load_opus(self):
""" Load the proper OPUS library for the device being used """
processor = platform.machine()
script_dir = os.path.dirname(os.path.abspath(__file__))
LOGGER.debug("Processor: ", processor)
if os.name == 'nt':
if processor == "AMD64":
opus.load_opus(os.path.join(script_dir, './opus/libopus_amd64.dll'))
LOGGER.info("Loaded OPUS library for AMD64")
return "AMD64"
else:
if processor == "aarch64":
opus.load_opus(os.path.join(script_dir, './opus/libopus_aarcch64.so'))
LOGGER.info("Loaded OPUS library for aarch64")
return "aarch64"
elif processor == "armv7l":
opus.load_opus(os.path.join(script_dir, './opus/libopus_armv7l.so'))
LOGGER.info("Loaded OPUS library for armv7l")
return "armv7l"
async def set_presence(self, presence: str):
""" Set the presense (activity) of the bot """
try:
await self.bot.change_presence(activity=Activity(type=ActivityType.listening, name=presence))
except Exception as pe:
LOGGER.error(f"Unable to set presence: '{pe}'")

View File

@@ -8,4 +8,4 @@ for inputDevice in list_of_devices['Input']:
print("----- OUTPUT DEVICES -----") print("----- OUTPUT DEVICES -----")
for outputDevice in list_of_devices['Output']: for outputDevice in list_of_devices['Output']:
print(f"{outputDevice}\t-\t{list_of_devices['Output'][outputDevice]}") print(f"{outputDevice}\t-\t{list_of_devices['Output'][outputDevice]}")

55
app/internal/logger.py Normal file
View File

@@ -0,0 +1,55 @@
import logging
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')

15
app/main.py Normal file
View File

@@ -0,0 +1,15 @@
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
# Initialize logging
LOGGER = create_logger(__name__)
# Define FastAPI app
app = FastAPI()
app.include_router(op25_controller.router, prefix="/op25")
app.include_router(pulse.router, prefix="/pulse")
app.include_router(bot.router, prefix="/bot")

104
app/models.py Normal file
View File

@@ -0,0 +1,104 @@
from pydantic import BaseModel
from typing import List, Optional, Union
from enum import Enum
class BotConfig(BaseModel):
token: str
class VoiceChannelJoinRequest(BaseModel):
guild_id: int
channel_id: int
class VoiceChannelLeaveRequest(BaseModel):
guild_id: int
class DecodeMode(str, Enum):
P25 = "P25"
DMR = "DMR"
ANALOG = "NBFM"
class TalkgroupTag(BaseModel):
talkgroup: str
tagDec: int
class ConfigGenerator(BaseModel):
type: DecodeMode
systemName: str
channels: List[Union[int, str]]
tags: Optional[List[TalkgroupTag]]
whitelist: Optional[List[int]]
class DemodType(str, Enum):
CQPSK = "cqpsk"
FSK4 = "fsk4"
class FilterType(str, Enum):
RC = "rc"
WIDEPULSE = "widepulse"
class ChannelConfig(BaseModel):
name: str
trunking_sysname: Optional[str]
enable_analog: str
demod_type: DemodType
filter_type: FilterType
device: Optional[str] = "sdr"
cqpsk_tracking: Optional[bool] = None
frequency: Optional[float] = None
nbfmSquelch: Optional[float] = None
destination: Optional[str] = "udp://127.0.0.1:23456"
tracking_threshold: Optional[int] = 120
tracking_feedback: Optional[float] = 0.75
excess_bw: Optional[float] = 0.2
if_rate: Optional[int] = 24000
plot: Optional[str] = ""
symbol_rate: Optional[int] = 4800
blacklist: Optional[str] = ""
whitelist: Optional[str] = ""
class DeviceConfig(BaseModel):
args: Optional[str] = "rtl"
gains: Optional[str] = "lna:39"
gain_mode: Optional[bool] = False
name: Optional[str] = "sdr"
offset: Optional[int] = 0
ppm: Optional[float] = 0.0
rate: Optional[int] = 1920000
usable_bw_pct: Optional[float] = 0.85
tunable: Optional[bool] = True
class TrunkingChannelConfig(BaseModel):
sysname: str
control_channel_list: str
tagsFile: Optional[str] = None
whitelist: Optional[str] = None
nac: Optional[str] = ""
wacn: Optional[str] = ""
tdma_cc: Optional[bool] = False
crypt_behavior: Optional[int] = 2
class TrunkingConfig(BaseModel):
module: str
chans: List[TrunkingChannelConfig]
class AudioInstanceConfig(BaseModel):
instance_name: Optional[str] = "audio0"
device_name: Optional[str] = "pulse"
udp_port: Optional[int] = 23456
audio_gain: Optional[float] = 2.5
number_channels: Optional[int] = 1
class AudioConfig(BaseModel):
module: Optional[str] = "sockaudio.py"
instances: Optional[List[AudioInstanceConfig]] = [AudioInstanceConfig()]
class TerminalConfig(BaseModel):
module: Optional[str] = "terminal.py"
terminal_type: Optional[str] = "http:0.0.0.0:8081"
terminal_timeout: Optional[float] = 5.0
curses_plot_interval: Optional[float] = 0.2
http_plot_interval: Optional[float] = 1.0
http_plot_directory: Optional[str] = "../www/images"
tuning_step_large: Optional[int] = 1200
tuning_step_small: Optional[int] = 100

57
app/routers/bot.py Normal file
View File

@@ -0,0 +1,57 @@
from fastapi import APIRouter, HTTPException
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()
# 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))
@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("/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.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

View File

@@ -1,14 +1,15 @@
from fastapi import FastAPI, HTTPException, APIRouter from fastapi import HTTPException, APIRouter
from pydantic import BaseModel
from enum import Enum
import subprocess import subprocess
import os import os
import signal import signal
import json import json
import csv import csv
from typing import List, Optional, Union from models import ConfigGenerator, DecodeMode, ChannelConfig, DeviceConfig, TrunkingConfig, TrunkingChannelConfig, AudioConfig, TerminalConfig, TalkgroupTag
from internal.logger import create_logger
from typing import List
router = APIRouter() router = APIRouter()
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/"
@@ -20,7 +21,7 @@ async def start_op25():
if op25_process is None: if op25_process is None:
try: try:
op25_process = subprocess.Popen(os.path.join(OP25_PATH, OP25_SCRIPT), shell=True, preexec_fn=os.setsid, cwd=OP25_PATH) op25_process = subprocess.Popen(os.path.join(OP25_PATH, OP25_SCRIPT), shell=True, preexec_fn=os.setsid, cwd=OP25_PATH)
print(op25_process) LOGGER.debug(op25_process)
return {"status": "OP25 started"} return {"status": "OP25 started"}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@@ -44,96 +45,6 @@ async def stop_op25():
async def get_status(): async def get_status():
return {"status": "running" if op25_process else "stopped"} return {"status": "running" if op25_process else "stopped"}
class DecodeMode(str, Enum):
P25 = "P25"
DMR = "DMR"
ANALOG = "NBFM"
class TalkgroupTag(BaseModel):
talkgroup: str
tagDec: int
class ConfigGenerator(BaseModel):
type: DecodeMode
systemName: str
channels: List[str]
tags: List[TalkgroupTag]
whitelist: List[int]
class DemodType(str, Enum):
CQPSK = "cqpsk"
FSK4 = "fsk4"
class FilterType(str, Enum):
RC = "rc"
WIDEPULSE = "widepulse"
class ChannelConfig(BaseModel):
name: str
trunking_sysname: Optional[str]
enable_analog: str
demod_type: DemodType
filter_type: FilterType
device: Optional[str] = "sdr"
cqpsk_tracking: Optional[bool] = None
frequency: Optional[float] = None
nbfmSquelch: Optional[float] = None
destination: Optional[str] = "udp://127.0.0.1:23456"
tracking_threshold: Optional[int] = 120
tracking_feedback: Optional[float] = 0.75
excess_bw: Optional[float] = 0.2
if_rate: Optional[int] = 24000
plot: Optional[str] = ""
symbol_rate: Optional[int] = 4800
blacklist: Optional[str] = ""
whitelist: Optional[str] = ""
class DeviceConfig(BaseModel):
args: Optional[str] = "rtl"
gains: Optional[str] = "lna:39"
gain_mode: Optional[bool] = False
name: Optional[str] = "sdr"
offset: Optional[int] = 0
ppm: Optional[float] = 0.0
rate: Optional[int] = 1920000
usable_bw_pct: Optional[float] = 0.85
tunable: Optional[bool] = True
class TrunkingChannelConfig(BaseModel):
sysname: str
control_channel_list: str
tagsFile: Optional[str] = None
whitelist: Optional[str] = None
nac: Optional[str] = ""
wacn: Optional[str] = ""
tdma_cc: Optional[bool] = False
crypt_behavior: Optional[int] = 2
class TrunkingConfig(BaseModel):
module: str
chans: List[TrunkingChannelConfig]
class AudioInstanceConfig(BaseModel):
instance_name: Optional[str] = "audio0"
device_name: Optional[str] = "pulse"
udp_port: Optional[int] = 23456
audio_gain: Optional[float] = 2.5
number_channels: Optional[int] = 1
class AudioConfig(BaseModel):
module: Optional[str] = "sockaudio.py"
instances: Optional[List[AudioInstanceConfig]] = [AudioInstanceConfig()]
class TerminalConfig(BaseModel):
module: Optional[str] = "terminal.py"
terminal_type: Optional[str] = "http:0.0.0.0:8081"
terminal_timeout: Optional[float] = 5.0
curses_plot_interval: Optional[float] = 0.2
http_plot_interval: Optional[float] = 1.0
http_plot_directory: Optional[str] = "../www/images"
tuning_step_large: Optional[int] = 1200
tuning_step_small: Optional[int] = 100
@router.post("/generate-config") @router.post("/generate-config")
async def generate_config(generator: ConfigGenerator): async def generate_config(generator: ConfigGenerator):
try: try:
@@ -170,7 +81,7 @@ async def generate_config(generator: ConfigGenerator):
"audio": audio.dict(), "audio": audio.dict(),
"terminal": terminal.dict() "terminal": terminal.dict()
} }
elif generator.type == DecodeMode.ANALOG: elif generator.type == DecodeMode.ANALOG:
generator = generator.config generator = generator.config
channels = [ChannelConfig( channels = [ChannelConfig(
@@ -190,11 +101,11 @@ async def generate_config(generator: ConfigGenerator):
else: else:
raise HTTPException(status_code=400, detail="Invalid configuration type. Must be 'p25' or 'nbfm'.") raise HTTPException(status_code=400, detail="Invalid configuration type. Must be 'p25' or 'nbfm'.")
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)
return {"message": f"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))
@@ -222,7 +133,7 @@ def save_whitelist(talkgroup_tags: List[int]) -> None:
writer = csv.writer(file, delimiter='\t', lineterminator='\n') writer = csv.writer(file, delimiter='\t', lineterminator='\n')
# Write rows # Write rows
for tag in talkgroup_tags: for tag in talkgroup_tags:
writer.writerow([tag]) writer.writerow([tag])
def del_none_in_dict(d): def del_none_in_dict(d):
""" """
@@ -231,7 +142,7 @@ def del_none_in_dict(d):
This alters the input so you may wish to ``copy`` the dict first. This alters the input so you may wish to ``copy`` the dict first.
""" """
for key, value in list(d.items()): for key, value in list(d.items()):
print(f"Key: '{key}'\nValue: '{value}'") LOGGER.info(f"Key: '{key}'\nValue: '{value}'")
if value is None: if value is None:
del d[key] del d[key]
elif isinstance(value, dict): elif isinstance(value, dict):
@@ -239,4 +150,4 @@ def del_none_in_dict(d):
elif isinstance(value, list): elif isinstance(value, list):
for iterative_value in value: for iterative_value in value:
del_none_in_dict(iterative_value) del_none_in_dict(iterative_value)
return d # For convenience return d # For convenience

View File

@@ -11,4 +11,4 @@ async def get_status():
return {"status": "running" if pulse_process else "stopped"} return {"status": "running" if pulse_process else "stopped"}
# subprocess.Popen(os.path.join(OP25_PATH, OP25_SCRIPT), shell=True, preexec_fn=os.setsid, cwd=OP25_PATH) # subprocess.Popen(os.path.join(OP25_PATH, OP25_SCRIPT), shell=True, preexec_fn=os.setsid, cwd=OP25_PATH)

54
op25.liq Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/liquidsoap
# Example liquidsoap streaming from op25 to icecast
# (c) 2019-2021 gnorbury@bondcar.com, wllmbecks@gmail.com
#
set("log.stdout", true)
set("log.file", false)
set("log.level", 1)
# Make the native sample rate compatible with op25
set("frame.audio.samplerate", 8000)
input = mksafe(input.external(buffer=0.25, channels=2, samplerate=8000, restart_on_error=false, "./audio.py -x 1.5 -s"))
# Consider increasing the buffer value on slow systems such as RPi3. e.g. buffer=0.25
# Longer buffer results in less choppy audio but at the expense of increased latency.
# OPTIONAL AUDIO SIGNAL PROCESSING BLOCKS
# Uncomment to enable
#
# High pass filter
#input = filter.iir.butterworth.high(frequency = 200.0, order = 4, input)
# Low pass filter
#input = filter.iir.butterworth.low(frequency = 3250.0, order = 4, input)
# Compression
input = compress(input, attack = 2.0, gain = 0.0, knee = 13.0, ratio = 2.0, release = 12.3, threshold = -18.0)
# Normalization
input = normalize(input, gain_max = 6.0, gain_min = -6.0, target = -16.0, threshold = -65.0)
# LOCAL AUDIO OUTPUT
# Uncomment the appropriate line below to enable local sound
#
# Default audio subsystem
#out (input)
#
# PulseAudio
#output.pulseaudio(input)
#
# ALSA
#output.alsa(input)
# ICECAST STREAMING
# Uncomment to enable output to an icecast server
# Change the "host", "password", and "mount" strings appropriately first!
# For metadata to work properly, the host address given here MUST MATCH the address in op25's meta.json file
#
output.icecast(%mp3(bitrate=16, samplerate=22050, stereo=false), description="op25", genre="Public Safety", url="", fallible=false, host="localhost", port=8000, mount="mountpoint", password="hackme", mean(input))

20
supervisord.conf Normal file
View File

@@ -0,0 +1,20 @@
[supervisord]
nodaemon=true
[program:op25-liq]
command=/usr/bin/liquidsoap /op25/op25.liq
directory=/op25
autostart=true
autorestart=true
stderr_logfile=/var/log/supervisor/op25-liq.err.log
stdout_logfile=/var/log/supervisor/op25-liq.out.log
user=root
[program:drb-client-discord]
command=uvicorn main:app --host 0.0.0.0 --port 8001 --reload
directory=/app
autostart=true
autorestart=true
stderr_logfile=/var/log/supervisor/main_app.err.log
stdout_logfile=/var/log/supervisor/main_app.out.log
user=root