11 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
13 changed files with 318 additions and 274 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

@@ -9,7 +9,7 @@ jobs:
release-image: release-image:
runs-on: ubuntu-latest runs-on: ubuntu-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

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ bot-poc.py
configs* configs*
.env .env
*.log* *.log*
.venv

View File

@@ -26,9 +26,7 @@ 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
@@ -39,9 +37,6 @@ 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
@@ -71,8 +66,5 @@ 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
# Add Supervisord configuration # Run the node script
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf ENTRYPOINT ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001", "--reload"]
# Modify the ENTRYPOINT to run Supervisord
ENTRYPOINT ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View File

@@ -165,9 +165,15 @@ class DiscordBotManager:
LOGGER.info("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

@@ -3,6 +3,7 @@ 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__)
@@ -10,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

@@ -5,15 +5,13 @@ from internal.logger import create_logger
LOGGER = create_logger(__name__) LOGGER = create_logger(__name__)
# Define FastAPI app # Function to create router
router = APIRouter() def create_bot_router(bot_manager: DiscordBotManager):
router = APIRouter()
# Initialize Discord Bot Manager # API Endpoints
bot_manager = DiscordBotManager() @router.post("/start_bot")
async def start_bot(config: BotConfig):
# API Endpoints
@router.post("/start_bot")
async def start_bot(config: BotConfig):
try: try:
await bot_manager.start_bot(config.token) await bot_manager.start_bot(config.token)
return {"status": "Bot started successfully."} return {"status": "Bot started successfully."}
@@ -21,8 +19,8 @@ async def start_bot(config: BotConfig):
LOGGER.error(f"Error starting bot: {e}") LOGGER.error(f"Error starting bot: {e}")
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@router.post("/stop_bot") @router.post("/stop_bot")
async def stop_bot(): async def stop_bot():
try: try:
await bot_manager.stop_bot() await bot_manager.stop_bot()
return {"status": "Bot stopped successfully."} return {"status": "Bot stopped successfully."}
@@ -30,8 +28,8 @@ async def stop_bot():
LOGGER.error(f"Error stopping bot: {e}") LOGGER.error(f"Error stopping bot: {e}")
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: VoiceChannelJoinRequest): 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}."}
@@ -39,8 +37,8 @@ async def join_voice_channel(request: VoiceChannelJoinRequest):
LOGGER.error(f"Error joining voice channel: {e}") LOGGER.error(f"Error joining voice channel: {e}")
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: VoiceChannelLeaveRequest): 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."}
@@ -48,10 +46,13 @@ async def leave_voice_channel(request: VoiceChannelLeaveRequest):
LOGGER.error(f"Error leaving voice channel: {e}") LOGGER.error(f"Error leaving voice channel: {e}")
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@router.get("/status") @router.get("/status")
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

@@ -3,20 +3,22 @@ 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 ConfigGenerator, DecodeMode, ChannelConfig, DeviceConfig, TrunkingConfig, TrunkingChannelConfig, AudioConfig, TerminalConfig, TalkgroupTag
from internal.logger import create_logger from internal.logger import create_logger
from typing import List 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"
@router.post("/start") def create_op25_router(bot_manager: DiscordBotManager):
async def start_op25(): router = APIRouter()
@router.post("/start")
async def start_op25():
global op25_process global op25_process
if op25_process is None: if op25_process is None:
try: try:
@@ -28,8 +30,8 @@ async def start_op25():
else: else:
return {"status": "OP25 already running"} return {"status": "OP25 already running"}
@router.post("/stop") @router.post("/stop")
async def stop_op25(): async def stop_op25():
global op25_process global op25_process
if op25_process is not None: if op25_process is not None:
try: try:
@@ -41,12 +43,12 @@ async def stop_op25():
else: else:
return {"status": "OP25 is not running"} return {"status": "OP25 is not running"}
@router.get("/status") @router.get("/status")
async def get_status(): async def get_status():
return {"status": "running" if op25_process else "stopped"} return {"status": "running" if op25_process else "stopped"}
@router.post("/generate-config") @router.post("/generate-config")
async def generate_config(generator: ConfigGenerator): async def generate_config(generator: ConfigGenerator):
try: try:
if generator.type == DecodeMode.P25: if generator.type == DecodeMode.P25:
channels = [ChannelConfig( channels = [ChannelConfig(
@@ -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

@@ -1,54 +0,0 @@
#!/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))

View File

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

View File

@@ -1,20 +0,0 @@
[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