ed8b271b4e
The librarian health check was a plain GET to '/', which only proved
Flask was listening - not that the service could actually take a query,
run it through its internal queue+worker, and answer back. So the cog
could load against a librarian whose worker was wedged or that couldn't
reach the bot on the return leg.
Replace it with a ping that travels the SAME path a real search does, on
both sides:
bot: QueryControl -> OUT_COMM_Q -> scan_queue -> awaiting_q
librarian: POST /ping -> librarian_queue -> worker pulls it off
(no Crossref/DOI search) -> pongs back with the same uuid
bot: /conjurer -> incoming_q -> scan_incoming matches uuid, wakes waiter
The cog enables only when that whole loop closes within 3s. This also
proves the librarian->bot return path, which a GET never did.
Safety: uuid is random per ping; the wait and POST are both bounded so
startup can't stall; a pong that finds no waiter is dropped (never
orphaned into IN_COMM_Q, which would make the cog post a bogus 'no
results' message); and a ping whose pong never returns is swept out of
awaiting_q after PING_TTL_SECONDS so nothing leaks. All awaiting_q writes
stay within scan_queue (append) and scan_incoming (remove) - no locks,
no cross-thread mutation.
Integration tests cover: OK round-trip, timeout when accepted-but-no-pong,
unreachable, non-200, orphan-pong-dropped, and that real results still
reach IN_COMM_Q. Suite: 24 integration + 41 unit green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
420 lines
16 KiB
Python
420 lines
16 KiB
Python
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import threading
|
|
import time
|
|
import uuid as uuidlib
|
|
from queue import Empty, Queue
|
|
from typing import Optional
|
|
from urllib import request as urequest
|
|
|
|
import requests
|
|
from flask import Flask, abort, jsonify, request
|
|
from waitress import serve
|
|
|
|
HOST_ADDRESS = os.getenv("CONJURER_DISCORD_HOST", "192.168.1.92")
|
|
PORT_ADDRESS = int(os.getenv("CONJURER_DISCORD_PORT", "5000"))
|
|
ICECAST_ADDRESS = os.getenv("CONJURER_ICECAST", "http://192.168.1.12:8000")
|
|
API_KEY = os.getenv("CONJURER_API_KEY")
|
|
OUT_COMM_Q = Queue()
|
|
IN_COMM_Q = Queue()
|
|
# AI request queue: prompts to be answered by the bot's own AI backend (whatever
|
|
# $gadaj_teraz currently points at). Drained by the AI cog's worker loop, which
|
|
# calls handle_response and delivers the answer to the requested channel. Fed
|
|
# either over HTTP (POST /ai_query) or in-process via submit_ai_query().
|
|
AI_QUERY_Q = Queue()
|
|
SRCHTITLE = re.compile(rb"StreamTitle=\\*(?P<title>[^;]*);").search
|
|
|
|
awaiting_q = []
|
|
incoming_q = Queue()
|
|
# A health-check ping whose pong never comes back (dead/dropped librarian) would
|
|
# otherwise leave its record in awaiting_q forever. scan_incoming sweeps ping
|
|
# records older than this. Kept well above the ping timeout so a slow-but-alive
|
|
# round-trip is never swept out from under a waiter.
|
|
PING_TTL_SECONDS = 30
|
|
app = Flask(__name__)
|
|
|
|
|
|
def _authorize_request() -> None:
|
|
"""Reject inbound calls lacking the shared key (no-op if key is unset)."""
|
|
if API_KEY and request.headers.get("X-Conjurer-Api-Key") != API_KEY:
|
|
abort(401)
|
|
PREPPED_TRACKS = {
|
|
"requests": "",
|
|
"hit": "",
|
|
"all": "",
|
|
"priority": "",
|
|
"jingles": "",
|
|
"now_playing": "",
|
|
"next": "",
|
|
"meta": "",
|
|
}
|
|
logger = logging.getLogger("discord")
|
|
|
|
|
|
class QueryControl:
|
|
"""
|
|
This class `QueryControl` is used to manage queries with information about the author, UUID,
|
|
content, logger, context, and replies.
|
|
"""
|
|
|
|
def __init__(self, query_author, query_uuid, query_content, ctx, ai_review=False) -> None:
|
|
self.author = query_author
|
|
self.uuid = query_uuid
|
|
self.content = query_content
|
|
self.logger = logging.getLogger("discord")
|
|
self.stop = False
|
|
self.ctx = ctx
|
|
# When True, once the librarian returns hits, the DOI list + the search
|
|
# phrase are sent to the AI backend for a weighted-relevance re-rank and
|
|
# source review (see librarian_commands.check_data_q).
|
|
self.ai_review = ai_review
|
|
self.logger.info(
|
|
f"Created Query control for {self.author}, {self.uuid}: {self.content}"
|
|
)
|
|
self.replies = []
|
|
|
|
|
|
@app.route("/prepped_tracks", methods=["POST"])
|
|
def log_radio_tracks():
|
|
_authorize_request()
|
|
app.logger = logging.getLogger("discord")
|
|
|
|
app.logger.info(request)
|
|
record = json.loads(request.data)
|
|
app.logger.info(record)
|
|
if "next" in record[0]:
|
|
metadata = id3(ICECAST_ADDRESS)
|
|
PREPPED_TRACKS["now_playing"] = PREPPED_TRACKS["next"]
|
|
if metadata:
|
|
PREPPED_TRACKS["meta"] = (
|
|
metadata["name"]
|
|
+ " - "
|
|
+ metadata["title"]
|
|
+ "("
|
|
+ metadata["genre"]
|
|
+ ")"
|
|
)
|
|
else:
|
|
PREPPED_TRACKS["meta"] = "Nie znaju"
|
|
PREPPED_TRACKS[record[0]] = record[1]
|
|
app.logger.info("DATA RECEIVED")
|
|
return jsonify("SUCCESS")
|
|
|
|
|
|
@app.route("/conjurer", methods=["POST"])
|
|
def answer_external_command():
|
|
"""
|
|
The function `answer_external_command` logs the request data, loads the data as JSON, logs the
|
|
record, and then puts the record into an incoming queue before returning a success message.
|
|
:return: The function `answer_external_command()` is returning a JSON response with the message
|
|
"SUCCESS".
|
|
"""
|
|
_authorize_request()
|
|
logger = logging.getLogger("discord")
|
|
logger.info(request)
|
|
record = json.loads(request.data)
|
|
logger.info(record)
|
|
logger.info("DATA RECEIVED")
|
|
incoming_q.put(record)
|
|
return jsonify("SUCCESS")
|
|
|
|
|
|
def submit_ai_query(prompt, channel_id=None, request_type="NONE", username="conjurer", query_uuid=None):
|
|
"""Queue an AI prompt for the bot to answer with its configured backend.
|
|
|
|
In-process entry point (used by the librarian result handler). ``channel_id``
|
|
is the Discord channel the answer should be posted to; ``request_type`` is
|
|
passed through to handle_response ("NONE" keeps it a clean one-shot that does
|
|
not touch conversation memory).
|
|
"""
|
|
AI_QUERY_Q.put(
|
|
{
|
|
"uuid": query_uuid,
|
|
"prompt": prompt,
|
|
"channel_id": channel_id,
|
|
"request_type": request_type,
|
|
"username": username,
|
|
}
|
|
)
|
|
|
|
|
|
@app.route("/ai_query", methods=["POST"])
|
|
def ai_query():
|
|
"""Inbound AI query: queue a prompt to be answered by the bot's AI backend.
|
|
|
|
Payload: {"prompt": str, "channel_id": int, "request_type"?: str,
|
|
"username"?: str, "uuid"?: str}. The prompt is queued and answered
|
|
asynchronously by the AI cog's worker; the answer is posted to channel_id.
|
|
"""
|
|
_authorize_request()
|
|
logger = logging.getLogger("discord")
|
|
record = json.loads(request.data)
|
|
prompt = record.get("prompt", "")
|
|
if not prompt:
|
|
return jsonify(isError=True, message="missing 'prompt'", statusCode=400, data=[]), 400
|
|
submit_ai_query(
|
|
prompt=prompt,
|
|
channel_id=record.get("channel_id"),
|
|
request_type=record.get("request_type", "NONE"),
|
|
username=record.get("username", "external"),
|
|
query_uuid=record.get("uuid"),
|
|
)
|
|
logger.info("Queued AI query (qsize=%s)", AI_QUERY_Q.qsize())
|
|
return (
|
|
jsonify(isError=False, message="Queued", statusCode=200, data={"qsize": AI_QUERY_Q.qsize()}),
|
|
200,
|
|
)
|
|
|
|
|
|
@app.route("/conjurer", methods=["GET"])
|
|
def check_alive():
|
|
"""
|
|
The function "check_alive" returns a JSON response with the message "ALIVE" when the "/conjurer"
|
|
route is accessed via a GET request.
|
|
:return: The code snippet is a Flask route that listens for GET requests to the "/conjurer"
|
|
endpoint. When a GET request is made to this endpoint, the function check_alive() is called, which
|
|
returns a JSON response with the message "ALIVE".
|
|
"""
|
|
return jsonify("ALIVE")
|
|
|
|
|
|
def flask_debug():
|
|
"""
|
|
The `flask_debug` function starts a Flask application in debug mode without using the reloader.
|
|
Do not use for production for fucks sake.
|
|
"""
|
|
logger = logging.getLogger("discord")
|
|
|
|
logger.info("Attempt debug")
|
|
# trunk-ignore(bandit/B201)
|
|
app.run(debug=True, use_reloader=False, host=HOST_ADDRESS, port=PORT_ADDRESS)
|
|
|
|
|
|
def waitress_run():
|
|
"""
|
|
The `waitress_run` function serves the `app` on host "0.0.0.0"
|
|
and port 5000 using the Waitress WSGI server.
|
|
"""
|
|
logger = logging.getLogger("discord")
|
|
|
|
logger.info("Attempt waitress")
|
|
serve(app, host=HOST_ADDRESS, port=PORT_ADDRESS)
|
|
|
|
|
|
def scan_queue(stop_event: Optional[threading.Event] = None):
|
|
"""
|
|
The function `scan_queue` reads data from a queue, logs it, and appends it to another queue.
|
|
|
|
A bounded ``get(timeout=1)`` is used instead of a blocking ``get()`` so the
|
|
worker can observe ``stop_event`` and exit cleanly during shutdown.
|
|
|
|
:param stop_event: optional :class:`threading.Event`; when set the loop
|
|
stops at the next iteration.
|
|
"""
|
|
logger = logging.getLogger("discord")
|
|
while True:
|
|
if stop_event and stop_event.is_set():
|
|
logger.info("scan_queue: stop requested")
|
|
break
|
|
try:
|
|
data = OUT_COMM_Q.get(timeout=1)
|
|
except Empty:
|
|
continue
|
|
logger.info(data)
|
|
awaiting_q.append(data)
|
|
|
|
|
|
def scan_incoming(stop_event: Optional[threading.Event] = None):
|
|
"""
|
|
The `scan_incoming` function continuously checks for incoming data, processes it, and logs when data
|
|
is found.
|
|
|
|
:param stop_event: optional :class:`threading.Event`; when set the loop
|
|
stops at the next iteration.
|
|
"""
|
|
logger = logging.getLogger("discord")
|
|
while True:
|
|
if stop_event and stop_event.is_set():
|
|
logger.info("scan_incoming: stop requested")
|
|
break
|
|
# Sweep stale health-check pings first: if a librarian is dead the pong
|
|
# never arrives, so drop ping records past their TTL. scan_incoming is
|
|
# the sole remover of awaiting_q, so this needs no lock (scan_queue only
|
|
# appends). Snapshot with list() so removal during iteration is safe.
|
|
now = time.monotonic()
|
|
for record in list(awaiting_q):
|
|
if (
|
|
getattr(record, "is_ping", False)
|
|
and getattr(record, "answered", None) is not None
|
|
and not record.answered.is_set()
|
|
and now - getattr(record, "created", now) > PING_TTL_SECONDS
|
|
):
|
|
awaiting_q.remove(record)
|
|
try:
|
|
answer = incoming_q.get(block=False)
|
|
logger.info("DATA FOUND")
|
|
# Health-check pong (shape: {"__pong__": uuid}). Close the waiter's
|
|
# event and drop its record. It must NEVER fall through to the
|
|
# result/orphan path below, or the librarian cog would later pull it
|
|
# off IN_COMM_Q and post a bogus "no results" message to Discord.
|
|
if isinstance(answer, dict) and "__pong__" in answer:
|
|
pong_uuid = answer["__pong__"]
|
|
for record in list(awaiting_q):
|
|
if getattr(record, "uuid", None) == pong_uuid:
|
|
event = getattr(record, "answered", None)
|
|
if event is not None:
|
|
event.set()
|
|
awaiting_q.remove(record)
|
|
logger.info("PONG matched for %s", pong_uuid)
|
|
continue
|
|
# Collect matched records and drop them from awaiting_q afterwards -
|
|
# they used to stay forever (awaiting_q only ever grew), leaking
|
|
# memory over the bot's uptime and letting a reused UUID re-match a
|
|
# stale record.
|
|
matched = []
|
|
for record in awaiting_q:
|
|
if record.uuid in answer.keys():
|
|
record.stop = True
|
|
record.entries = answer[record.uuid]
|
|
IN_COMM_Q.put(record)
|
|
matched.append(record)
|
|
for record in matched:
|
|
awaiting_q.remove(record)
|
|
if not matched:
|
|
for key in answer.keys():
|
|
record = QueryControl("Orphaned", key, "Orphan", None)
|
|
record.stop = True
|
|
record.entries = answer[record.uuid]
|
|
IN_COMM_Q.put(record)
|
|
except Empty:
|
|
time.sleep(1)
|
|
|
|
|
|
def librarian_ping(address: str, endpoint: str, headers: Optional[dict] = None,
|
|
timeout: float = 3.0) -> bool:
|
|
"""Health-check the librarian by round-tripping a ping through the FULL path.
|
|
|
|
The ping is a pseudo-query that exercises exactly the same machinery a real
|
|
search does, on BOTH sides:
|
|
|
|
* bot out: a ``QueryControl`` rides ``OUT_COMM_Q`` -> ``scan_queue`` ->
|
|
``awaiting_q`` just like a real query,
|
|
* librarian: it is POSTed to the librarian, which must pull it off its OWN
|
|
internal queue and answer WITHOUT running a search,
|
|
* bot in: the pong comes back over ``/conjurer`` -> ``incoming_q`` ->
|
|
``scan_incoming``, which matches it by uuid and sets our event.
|
|
|
|
Returns True only when that whole loop closes within ``timeout``. Never
|
|
blocks longer than roughly ``timeout`` and cannot deadlock: the POST is
|
|
bounded, the wait is bounded, and a ping whose pong never arrives is swept
|
|
out of ``awaiting_q`` by ``scan_incoming`` (PING_TTL_SECONDS).
|
|
"""
|
|
logger = logging.getLogger("discord")
|
|
ping_uuid = str(uuidlib.uuid4())
|
|
answered = threading.Event()
|
|
query = QueryControl("healthcheck", ping_uuid, "__ping__", None)
|
|
query.is_ping = True
|
|
query.answered = answered
|
|
query.created = time.monotonic()
|
|
# Enter the bot-side comm queue BEFORE the POST, so the record is already in
|
|
# awaiting_q by the time the pong can come back (no lost-wakeup race).
|
|
OUT_COMM_Q.put(query)
|
|
try:
|
|
response = requests.post(
|
|
f"{address}{endpoint}",
|
|
json={"UUID": ping_uuid},
|
|
headers=headers or {},
|
|
timeout=timeout,
|
|
)
|
|
except requests.exceptions.RequestException as exc:
|
|
logger.info("Librarian ping POST failed (%s): %s", ping_uuid, exc)
|
|
return False # stale record is swept by scan_incoming
|
|
if response.status_code != 200:
|
|
logger.info("Librarian ping rejected (%s): HTTP %s", ping_uuid, response.status_code)
|
|
return False
|
|
if answered.wait(timeout):
|
|
logger.info("Librarian ping round-trip OK (%s)", ping_uuid)
|
|
return True
|
|
logger.info("Librarian ping timed out after %ss (%s)", timeout, ping_uuid)
|
|
return False
|
|
|
|
|
|
def get_stream_title(tag: bytes) -> str:
|
|
title = ""
|
|
if m := SRCHTITLE(tag):
|
|
# decode, strip, unescape and remove surrounding quotes (may not even be the same type of quote)
|
|
title = m.group("title").decode("utf-8").strip().replace("\\", "")[1:-1]
|
|
return title
|
|
|
|
|
|
def id3(url: str) -> dict:
|
|
request = urequest.Request(url, headers={"Icy-MetaData": 1})
|
|
|
|
with urequest.urlopen(request) as resp:
|
|
metaint = int(resp.headers.get("icy-metaint", "-1"))
|
|
if metaint < 0:
|
|
return False
|
|
resp.read(
|
|
metaint
|
|
) # this isn't seekable so, arbitrarily read to the point we want
|
|
# Guard the headers: an Icecast stream that omits icy-name / icy-genre
|
|
# (e.g. while the radio is down) made `.title()` raise AttributeError on
|
|
# None, 500-ing the /prepped_tracks "next" handler that calls this.
|
|
tagdata = dict(
|
|
site_url=resp.headers.get("icy-url"),
|
|
name=(resp.headers.get("icy-name") or "").title(),
|
|
genre=(resp.headers.get("icy-genre") or "").title(),
|
|
title=get_stream_title(resp.read(255)),
|
|
)
|
|
return tagdata
|
|
|
|
|
|
def comm_subroutine(stop_event: Optional[threading.Event] = None):
|
|
"""
|
|
The `comm_subroutine` function starts multiple threads to run different tasks concurrently.
|
|
|
|
Workers run as daemon threads and honour an optional ``stop_event`` so the
|
|
bot can shut the communication layer down cleanly instead of blocking
|
|
forever on ``join()``.
|
|
|
|
:param stop_event: optional :class:`threading.Event` shared with the caller
|
|
to coordinate a cooperative shutdown.
|
|
"""
|
|
logger = logging.getLogger("discord")
|
|
logger.setLevel(logging.DEBUG)
|
|
logger.info("Started comms")
|
|
threads = []
|
|
# NOTE: flask_debug is the dev server bound to the SAME host:port as
|
|
# waitress - running both kills the comm layer with 'address in use'.
|
|
# Enable it only INSTEAD of waitress_run, never alongside.
|
|
# threads.append(threading.Thread(target=flask_debug))
|
|
threads.append(threading.Thread(target=waitress_run, daemon=True))
|
|
threads.append(
|
|
threading.Thread(
|
|
target=scan_queue, kwargs={"stop_event": stop_event}, daemon=True
|
|
)
|
|
)
|
|
threads.append(
|
|
threading.Thread(
|
|
target=scan_incoming, kwargs={"stop_event": stop_event}, daemon=True
|
|
)
|
|
)
|
|
|
|
for worker in threads:
|
|
worker.start()
|
|
|
|
try:
|
|
while any(thread.is_alive() for thread in threads):
|
|
if stop_event and stop_event.is_set():
|
|
break
|
|
time.sleep(0.5)
|
|
finally:
|
|
if stop_event:
|
|
stop_event.set()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
comm_subroutine()
|