파일
bdi_podman_serverconf/containers/talkbot/server.py
T

1488 라인
34 KiB
Python

import json
import logging
import os
import re
import sqlite3
import subprocess
import threading
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("talkbot")
NC_URL = os.environ["NC_URL"].rstrip("/")
NC_USER = os.environ["NC_USER"]
NC_PASSWORD = os.environ["NC_PASSWORD"]
WHISPER_URL = os.environ.get("WHISPER_URL", "http://whisper:8080").rstrip("/")
LLAMACPP_URL = os.environ.get("LLAMACPP_URL", "http://llamacpp:7000").rstrip("/")
LLAMACPP_MODEL = os.environ.get("LLAMACPP_MODEL", "").strip()
LLAMACPP_TIMEOUT = int(os.environ.get("LLAMACPP_TIMEOUT") or "600")
LLAMACPP_ENABLE_THINKING = (
os.environ.get("LLAMACPP_ENABLE_THINKING", "false").lower()
in {"1", "true", "yes", "on"}
)
QWEN_TTS_URL = os.environ.get("QWEN_TTS_URL", "http://qwen-tts:8000").rstrip("/")
QWEN_TTS_TIMEOUT = int(os.environ.get("QWEN_TTS_TIMEOUT") or "1800")
DEFAULT_TARGET_LANGUAGE = os.environ.get(
"DEFAULT_TARGET_LANGUAGE",
"it",
).strip()
QWEN_TTS_SPEAKER = os.environ.get(
"QWEN_TTS_SPEAKER",
"Ryan",
).strip()
VOICE_SAMPLES_DIR = os.environ.get(
"VOICE_SAMPLES_DIR",
"/app/voice-samples",
)
QUEUE_DB_PATH = os.environ.get(
"QUEUE_DB_PATH",
"/app/data/queue.db",
)
# A job is retried up to this many times (across restarts, since the
# attempt count lives in the queue row) before being abandoned. Between
# attempts we wait RETRY_BACKOFF_SECONDS so a job that fails because a
# downstream service is briefly unavailable doesn't spin the worker in
# a tight loop.
MAX_ATTEMPTS = 3
RETRY_BACKOFF_SECONDS = 20
MANAGER_USER = NC_USER
app = FastAPI(title="Nextcloud Talk voice translator")
# Events are processed one at a time by a single background worker: the
# pipeline (whisper -> llama.cpp -> qwen-tts) is heavy and the host has
# limited resources, so we serialize instead of processing concurrently.
# The webhook handler only validates/parses the payload (no blocking I/O)
# and enqueues the job, so Nextcloud always gets an immediate response
# and never times out waiting for a translation to finish.
#
# The queue itself lives in a SQLite file (QUEUE_DB_PATH) instead of
# memory: a job row is only deleted after it has been fully processed,
# so if the container is killed/restarted mid-queue, whatever is still
# in the table gets picked up and processed again on the next startup.
new_job_signal = threading.Event()
def init_queue_db() -> None:
os.makedirs(os.path.dirname(QUEUE_DB_PATH), exist_ok=True)
with sqlite3.connect(QUEUE_DB_PATH, timeout=30) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
payload TEXT NOT NULL,
created_at TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TEXT
)
"""
)
existing_columns = {
row[1] for row in conn.execute("PRAGMA table_info(queue)")
}
if "attempts" not in existing_columns:
conn.execute(
"ALTER TABLE queue ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0"
)
if "next_attempt_at" not in existing_columns:
conn.execute("ALTER TABLE queue ADD COLUMN next_attempt_at TEXT")
conn.commit()
def enqueue_event(job: dict) -> None:
with sqlite3.connect(QUEUE_DB_PATH, timeout=30) as conn:
conn.execute(
"INSERT INTO queue (payload, created_at) VALUES (?, ?)",
(
json.dumps(job),
datetime.now(timezone.utc).isoformat(),
),
)
conn.commit()
new_job_signal.set()
def dequeue_next_event() -> Optional[tuple[int, dict, int]]:
now = datetime.now(timezone.utc).isoformat()
with sqlite3.connect(QUEUE_DB_PATH, timeout=30) as conn:
row = conn.execute(
"""
SELECT id, payload, attempts FROM queue
WHERE next_attempt_at IS NULL OR next_attempt_at <= ?
ORDER BY id LIMIT 1
""",
(now,),
).fetchone()
if not row:
return None
row_id, payload, attempts = row
return row_id, json.loads(payload), attempts
def reschedule_event(row_id: int, attempts: int) -> None:
next_attempt_at = (
datetime.now(timezone.utc) + timedelta(seconds=RETRY_BACKOFF_SECONDS)
).isoformat()
with sqlite3.connect(QUEUE_DB_PATH, timeout=30) as conn:
conn.execute(
"UPDATE queue SET attempts = ?, next_attempt_at = ? WHERE id = ?",
(attempts, next_attempt_at, row_id),
)
conn.commit()
def remove_queued_event(row_id: int) -> None:
with sqlite3.connect(QUEUE_DB_PATH, timeout=30) as conn:
conn.execute("DELETE FROM queue WHERE id = ?", (row_id,))
conn.commit()
def queue_size() -> int:
with sqlite3.connect(QUEUE_DB_PATH, timeout=30) as conn:
return conn.execute("SELECT COUNT(*) FROM queue").fetchone()[0]
LANGUAGE_NAMES = {
"en": "English",
"it": "Italian",
"de": "German",
"fr": "French",
"es": "Spanish",
"pt": "Portuguese",
"nl": "Dutch",
"pl": "Polish",
"ru": "Russian",
"zh": "Chinese",
"ja": "Japanese",
"ko": "Korean",
}
LANGUAGE_CODES = {
"en": "EN",
"it": "IT",
"de": "DE",
"fr": "FR",
"es": "ES",
"pt": "PT",
"nl": "NL",
"pl": "PL",
"ru": "RU",
"zh": "ZH",
"ja": "JA",
"ko": "KO",
}
def normalize_language_code(language: str) -> str:
language = (language or "").strip().lower()
if not language:
return DEFAULT_TARGET_LANGUAGE.lower()
if "-" in language:
language = language.split("-", 1)[0]
if "_" in language:
language = language.split("_", 1)[0]
return language
def language_name(language: str) -> str:
code = normalize_language_code(language)
return LANGUAGE_NAMES.get(
code,
language if language else LANGUAGE_NAMES.get(
normalize_language_code(DEFAULT_TARGET_LANGUAGE),
"English",
),
)
def language_short_code(language: str) -> str:
code = normalize_language_code(language)
return LANGUAGE_CODES.get(code, code.upper()[:8])
def sanitize_filename_part(value: str) -> str:
value = (value or "").strip()
value = re.sub(r"[^\w.-]+", "_", value, flags=re.UNICODE)
value = value.strip("._-")
return value or "User"
def verify_webhook_backend(
random_header: Optional[str],
signature_header: Optional[str],
body: bytes,
) -> bool:
if not random_header or not signature_header:
return False
import hashlib
import hmac
secret = os.environ.get("NC_BOT_SECRET", "")
if not secret:
return True
digest = hmac.new(
secret.encode(),
(random_header + body.decode("utf-8", errors="ignore")).encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(
digest.lower(),
signature_header.lower(),
)
def get_user_language(user_id: str) -> str:
try:
response = httpx.get(
f"{NC_URL}/ocs/v1.php/cloud/users/{user_id}",
headers={
"OCS-APIRequest": "true",
"Accept": "application/json",
},
auth=(NC_USER, NC_PASSWORD),
timeout=15,
)
response.raise_for_status()
data = response.json()
language = (
data
.get("ocs", {})
.get("data", {})
.get("language")
)
if language:
return language
except Exception as exc:
log.warning(
"Could not fetch language for user %s: %s",
user_id,
exc,
)
return DEFAULT_TARGET_LANGUAGE
def extract_file_param(parameters) -> Optional[dict]:
if isinstance(parameters, dict):
values = list(parameters.values())
elif isinstance(parameters, list):
values = parameters
else:
values = []
for value in values:
if isinstance(value, dict) and value.get("type") == "file":
return value
return None
def actor_user_id(actor: dict) -> str:
actor_id = str(actor.get("id", "") or "")
if "/" in actor_id:
return actor_id.split("/", 1)[-1]
return actor_id
def get_conversation_participants(token: str) -> list[dict]:
response = httpx.get(
f"{NC_URL}/ocs/v2.php/apps/spreed/api/v4/room/{token}/participants",
headers={
"OCS-APIRequest": "true",
"Accept": "application/json",
},
auth=(NC_USER, NC_PASSWORD),
timeout=30,
)
response.raise_for_status()
data = response.json().get("ocs", {}).get("data", [])
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if isinstance(data, dict):
for key in ("participants", "data", "users"):
value = data.get(key)
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
return []
def get_target_user_ids(token: str, sender_id: str) -> list[str]:
result = []
for participant in get_conversation_participants(token):
actor_type = str(
participant.get("actorType")
or participant.get("type")
or ""
).lower()
if actor_type not in {"users", "user"}:
continue
actor_id = str(
participant.get("actorId")
or participant.get("id")
or ""
)
user_id = actor_id.split("/", 1)[-1].strip()
if not user_id:
continue
if user_id == sender_id:
continue
if user_id == MANAGER_USER:
continue
if user_id not in result:
result.append(user_id)
return result
def request_direct_download(file_id: int) -> str:
response = httpx.post(
f"{NC_URL}/ocs/v2.php/apps/dav/api/v1/direct",
headers={
"OCS-APIRequest": "true",
"Accept": "application/json",
"Content-Type": "application/json",
},
auth=(NC_USER, NC_PASSWORD),
json={
"fileId": int(file_id),
"expirationTime": 600,
},
timeout=30,
)
log.info(
"Direct download request for file %s: HTTP %s",
file_id,
response.status_code,
)
response.raise_for_status()
data = response.json()
ocs_data = data.get("ocs", {}).get("data", {})
url = None
if isinstance(ocs_data, dict):
url = (
ocs_data.get("url")
or ocs_data.get("link")
or ocs_data.get("downloadUrl")
)
if not url and isinstance(ocs_data, str):
url = ocs_data
if not url:
raise RuntimeError(
f"Nextcloud did not return a direct download URL for file {file_id}"
)
if url.startswith("https://"):
if NC_URL.startswith("http://"):
url = url.replace("https://", "http://", 1)
elif url.startswith("/"):
url = f"{NC_URL}{url}"
log.info(
"Normalized Direct Download URL for file %s",
file_id,
)
return url
def download_attachment(file_id: int) -> bytes:
direct_url = request_direct_download(file_id)
log.info(
"Downloading file through Direct Download: %s",
direct_url,
)
response = httpx.get(
direct_url,
timeout=120,
follow_redirects=True,
)
log.info(
"Direct download HTTP %s",
response.status_code,
)
response.raise_for_status()
log.info(
"Downloaded audio: %s bytes",
len(response.content),
)
return response.content
def is_blank_transcription(text: str) -> bool:
normalized = (text or "").strip().lower()
if not normalized:
return True
normalized = re.sub(r"[\s._-]+", "", normalized)
return normalized in {
"[blankaudio]",
"(blankaudio)",
"blankaudio",
"[silence]",
"(silence)",
"silence",
}
def convert_audio_to_wav(audio_bytes: bytes) -> bytes:
"""Convert any FFmpeg-supported audio format to Whisper's WAV format."""
try:
result = subprocess.run(
[
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-i",
"pipe:0",
"-vn",
"-ac",
"1",
"-ar",
"16000",
"-c:a",
"pcm_s16le",
"-f",
"wav",
"pipe:1",
],
input=audio_bytes,
capture_output=True,
check=False,
timeout=120,
)
except FileNotFoundError as exc:
raise RuntimeError("ffmpeg is not installed in the talkbot container") from exc
except subprocess.TimeoutExpired as exc:
raise RuntimeError("Audio conversion with ffmpeg timed out") from exc
if result.returncode != 0 or not result.stdout:
error = result.stderr.decode("utf-8", errors="replace").strip()
raise RuntimeError(f"Unable to convert audio to WAV: {error[:500]}")
log.info(
"Audio converted to WAV: %s -> %s bytes",
len(audio_bytes),
len(result.stdout),
)
return result.stdout
def transcribe(audio_bytes: bytes) -> tuple[str, str]:
wav_bytes = convert_audio_to_wav(audio_bytes)
response = httpx.post(
f"{WHISPER_URL}/inference",
files={
"file": (
"audio.wav",
wav_bytes,
"audio/wav",
)
},
data={
"response_format": "json",
},
timeout=300,
)
if response.is_error:
log.error(
"Whisper returned HTTP %s: %s",
response.status_code,
response.text[:500],
)
response.raise_for_status()
result = response.json()
text = str(result.get("text", "") or "").strip()
detected_language = str(
result.get("language", "auto") or "auto"
).strip()
return text, detected_language
def translate(
text: str,
source_language: str,
target_language: str,
) -> str:
source_name = language_name(source_language)
target_name = language_name(target_language)
prompt = (
f"Translate the following text from {source_name} "
f"to {target_name}.\n"
"Return only the translated text.\n"
"Do not explain the translation.\n"
"Do not answer the message.\n"
"Do not add comments.\n\n"
f"Text:\n{text}"
)
payload = {
"messages": [
{
"role": "system",
"content": (
"You are a professional translator. "
"You must translate exactly as requested."
),
},
{
"role": "user",
"content": prompt,
},
],
"temperature": 0.1,
"chat_template_kwargs": {
"enable_thinking": LLAMACPP_ENABLE_THINKING,
},
}
if LLAMACPP_MODEL:
payload["model"] = LLAMACPP_MODEL
response = httpx.post(
f"{LLAMACPP_URL}/v1/chat/completions",
json=payload,
timeout=LLAMACPP_TIMEOUT,
)
response.raise_for_status()
result = response.json()
return (
result["choices"][0]["message"]["content"]
.strip()
)
def find_voice_sample(
user_id: str,
) -> tuple[Optional[str], Optional[str]]:
safe_user_id = re.sub(
r"[^A-Za-z0-9_.-]",
"",
user_id,
)
if not safe_user_id:
return None, None
wav_path = os.path.join(
VOICE_SAMPLES_DIR,
f"{safe_user_id}.wav",
)
if not os.path.isfile(wav_path):
return None, None
txt_path = os.path.join(
VOICE_SAMPLES_DIR,
f"{safe_user_id}.txt",
)
ref_text = None
if os.path.isfile(txt_path):
with open(
txt_path,
"r",
encoding="utf-8",
) as file:
ref_text = file.read().strip() or None
return wav_path, ref_text
def synthesize(
text: str,
target_language: str,
sender_user_id: str,
) -> bytes:
target_name = language_name(target_language)
sample_path, ref_text = find_voice_sample(
sender_user_id,
)
if sample_path:
log.info(
"Using cloned voice from %s for user %s",
sample_path,
sender_user_id,
)
data = {
"text": text,
"language": target_name,
}
if ref_text:
data["ref_text"] = ref_text
else:
data["x_vector_only_mode"] = "true"
with open(sample_path, "rb") as reference_audio:
response = httpx.post(
f"{QWEN_TTS_URL}/speech/clone",
data=data,
files={
"ref_audio": (
os.path.basename(sample_path),
reference_audio,
"audio/wav",
)
},
timeout=QWEN_TTS_TIMEOUT,
)
else:
log.info(
"No voice sample for user %s, using default speaker %s",
sender_user_id,
QWEN_TTS_SPEAKER,
)
response = httpx.post(
f"{QWEN_TTS_URL}/speech",
json={
"text": text,
"language": target_name,
"speaker": QWEN_TTS_SPEAKER,
},
timeout=QWEN_TTS_TIMEOUT,
)
response.raise_for_status()
return response.content
def delete_message(
token: str,
message_id: int,
) -> bool:
url = (
f"{NC_URL}/ocs/v2.php/apps/spreed/api/v1/"
f"chat/{token}/{message_id}"
)
response = httpx.delete(
url,
headers={
"OCS-APIRequest": "true",
"Accept": "application/json",
},
auth=(NC_USER, NC_PASSWORD),
timeout=30,
)
log.info(
"Delete message %s: HTTP %s",
message_id,
response.status_code,
)
if response.status_code == 200:
log.info(
"Deleted original message %s",
message_id,
)
return True
if response.status_code == 202:
log.info(
"Deleted original message %s: HTTP 202",
message_id,
)
return True
if response.status_code == 403:
log.warning(
"Manager is not allowed to delete message %s "
"(Manager must be a moderator)",
message_id,
)
return False
if response.status_code == 404:
log.warning(
"Message %s was not found",
message_id,
)
return False
log.warning(
"Could not delete message %s: HTTP %s: %s",
message_id,
response.status_code,
response.text[:500],
)
return False
def ensure_manager_folder() -> None:
response = httpx.request(
"MKCOL",
f"{NC_URL}/remote.php/dav/files/"
f"{MANAGER_USER}/talk-bot-output/",
auth=(NC_USER, NC_PASSWORD),
timeout=30,
)
if response.status_code not in (201, 405):
response.raise_for_status()
def upload_audio(
wav_bytes: bytes,
filename: str,
) -> str:
ensure_manager_folder()
dav_path = (
f"/remote.php/dav/files/{MANAGER_USER}/"
f"talk-bot-output/{filename}"
)
response = httpx.put(
f"{NC_URL}{dav_path}",
content=wav_bytes,
auth=(NC_USER, NC_PASSWORD),
timeout=120,
)
response.raise_for_status()
share_path = f"/talk-bot-output/{filename}"
log.info(
"Uploaded generated audio as %s",
share_path,
)
return share_path
def share_file_to_talk(
token: str,
file_path: str,
caption: str,
) -> None:
share_response = httpx.post(
f"{NC_URL}/ocs/v2.php/apps/files_sharing/api/v1/shares",
headers={
"OCS-APIRequest": "true",
"Accept": "application/json",
},
auth=(NC_USER, NC_PASSWORD),
data={
"path": file_path,
"shareType": 10,
"shareWith": token,
"permissions": 1,
"talkMetaData": json.dumps({
"messageType": "voice-message",
"caption": caption,
}),
},
timeout=60,
)
log.info(
"Talk file share POST HTTP %s",
share_response.status_code,
)
if share_response.status_code not in (200, 201):
log.warning(
"Talk file share failed: %s",
share_response.text[:1000],
)
share_response.raise_for_status()
def post_manager_message(
token: str,
message: str,
) -> int:
response = httpx.post(
f"{NC_URL}/ocs/v2.php/apps/spreed/api/v1/chat/{token}",
headers={
"OCS-APIRequest": "true",
"Accept": "application/json",
},
auth=(NC_USER, NC_PASSWORD),
data={
"message": message,
},
timeout=30,
)
log.info(
"Manager message POST HTTP %s",
response.status_code,
)
response.raise_for_status()
try:
result = response.json()
data = result.get("ocs", {}).get("data", {})
if isinstance(data, dict):
if "id" in data:
return int(data["id"])
if isinstance(data, list) and data:
if isinstance(data[0], dict) and "id" in data[0]:
return int(data[0]["id"])
except Exception:
pass
return 0
def make_filename(
sender_display_name: str,
target_language: str,
) -> str:
sender = sanitize_filename_part(
sender_display_name,
)
lang = language_short_code(
target_language,
)
now = datetime.now()
return (
f"{sender}-"
f"{lang}-"
f"{now:%Y%m%d}-"
f"{now:%H%M}.wav"
)
def build_file_message(
file_path: str,
filename: str,
translated_text: str,
) -> str:
payload = {
"message": f"{translated_text}\n\n{{file}}",
"parameters": {
"file": {
"type": "file",
"name": filename,
"path": filename,
"mimetype": "audio/wav",
}
},
}
return json.dumps(payload)
def process_voice_message(
token: str,
message_id: int,
sender_user_id: str,
sender_display_name: str,
file_param: dict,
recipients: list[str],
) -> None:
file_id = file_param.get("id")
if not file_id:
raise RuntimeError(
"Voice attachment does not contain a file id"
)
file_id = int(file_id)
log.info(
"Voice message %s from %s in conversation %s",
message_id,
sender_user_id,
token,
)
log.info(
"File: id=%s name=%s mimetype=%s path=%s",
file_id,
file_param.get("name"),
file_param.get("mimetype"),
file_param.get("path"),
)
audio_bytes = download_attachment(file_id)
text, detected_language = transcribe(
audio_bytes,
)
log.info(
"Transcribed (%s): %s",
detected_language,
text,
)
if is_blank_transcription(text):
log.info(
"Whisper returned blank audio for message %s; ignoring the rest",
message_id,
)
return
sender_language = normalize_language_code(
get_user_language(sender_user_id),
)
log.info(
"Sender %s language: %s -> %s",
sender_user_id,
sender_language,
language_name(sender_language),
)
if not recipients:
log.warning(
"No translation recipients found for conversation %s",
token,
)
return
translations = []
for recipient in recipients:
recipient_language = get_user_language(
recipient,
)
recipient_language = normalize_language_code(
recipient_language,
)
recipient_language_name = language_name(
recipient_language,
)
log.info(
"Recipient %s language: %s -> %s",
recipient,
recipient_language,
recipient_language_name,
)
if detected_language == recipient_language:
translated_text = text
else:
translated_text = translate(
text,
sender_language,
recipient_language,
)
log.info(
"Translated (%s): %s",
recipient_language_name,
translated_text,
)
translations.append(
(
recipient,
recipient_language,
translated_text,
)
)
unique_translations = []
seen_languages = set()
for recipient, target_language, translated_text in translations:
if target_language in seen_languages:
continue
seen_languages.add(target_language)
unique_translations.append(
(
recipient,
target_language,
translated_text,
)
)
generated = []
for recipient, target_language, translated_text in unique_translations:
wav_bytes = synthesize(
translated_text,
target_language,
sender_user_id,
)
filename = make_filename(
sender_display_name,
target_language,
)
log.info(
"Generated filename: %s",
filename,
)
file_path = upload_audio(
wav_bytes,
filename,
)
generated.append(
(
target_language,
filename,
file_path,
)
)
if not generated:
return
deleted = delete_message(
token,
message_id,
)
if not deleted:
log.warning(
"Original message %s was not deleted. "
"Generated translations will not be posted "
"to avoid confusing duplicates.",
message_id,
)
return
for target_language, filename, file_path in generated:
translated_text = next(
translated_text
for _, lang, translated_text in unique_translations
if lang == target_language
)
share_file_to_talk(
token,
file_path,
f"@{sender_display_name}: {translated_text}",
)
log.info(
"Posted translated audio %s (%s)",
filename,
language_name(target_language),
)
def process_event(job: dict) -> None:
token = job["token"]
message_id = job["message_id"]
sender_user_id = job["sender_user_id"]
sender_display_name = job["sender_display_name"]
file_param = job.get("file_param")
message_text = job.get("message_text", "")
try:
recipients = get_target_user_ids(
token,
sender_user_id,
)
except Exception as exc:
log.exception(
"Could not load participants for conversation %s: %s",
token,
exc,
)
recipients = []
if file_param:
try:
process_voice_message(
token=token,
message_id=message_id,
sender_user_id=sender_user_id,
sender_display_name=sender_display_name,
file_param=file_param,
recipients=recipients,
)
except Exception as exc:
log.exception(
"Voice processing failed for message %s: %s",
message_id,
exc,
)
return
if message_text:
log.info(
"Text message %s from %s",
message_id,
sender_user_id,
)
translations = []
for recipient in recipients:
target_language = normalize_language_code(
get_user_language(recipient),
)
translated = translate(
message_text,
"auto",
target_language,
)
if translated.strip().lower() == message_text.lower():
continue
translations.append((recipient, target_language, translated))
if not translations:
return
deleted = delete_message(
token,
message_id,
)
if not deleted:
log.warning(
"Original message %s was not deleted. "
"Generated translations will not be posted "
"to avoid confusing duplicates.",
message_id,
)
return
for recipient, target_language, translated in translations:
post_manager_message(
token,
f"@{sender_display_name}: {translated}",
)
log.info(
"Posted text translation for %s: %s",
recipient,
target_language,
)
return
log.info(
"Message %s has no text or attachment",
message_id,
)
def notify_processing_failure(job: dict) -> None:
token = job.get("token")
if not token:
return
try:
post_manager_message(
token,
"Non è stato possibile elaborare il messaggio.",
)
except Exception:
log.exception(
"Could not post failure notice for message %s in conversation %s",
job.get("message_id"),
token,
)
def event_worker() -> None:
while True:
job_row = dequeue_next_event()
if job_row is None:
new_job_signal.wait(timeout=5)
new_job_signal.clear()
continue
row_id, job, attempts = job_row
attempts += 1
try:
process_event(job)
except Exception as exc:
log.exception(
"Error processing message %s (attempt %s/%s): %s",
job.get("message_id"),
attempts,
MAX_ATTEMPTS,
exc,
)
if attempts < MAX_ATTEMPTS:
reschedule_event(row_id, attempts)
continue
log.error(
"Giving up on message %s after %s attempts",
job.get("message_id"),
attempts,
)
notify_processing_failure(job)
remove_queued_event(row_id)
@app.on_event("startup")
def start_event_worker() -> None:
init_queue_db()
pending = queue_size()
if pending:
log.info(
"Resuming %s queued message(s) left over from a previous run",
pending,
)
threading.Thread(
target=event_worker,
name="talkbot-event-worker",
daemon=True,
).start()
@app.get("/health")
def health():
return {
"status": "ok",
"manager": MANAGER_USER,
"llamacpp_timeout": LLAMACPP_TIMEOUT,
"llamacpp_thinking": LLAMACPP_ENABLE_THINKING,
"qwen_tts_timeout": QWEN_TTS_TIMEOUT,
"queue_size": queue_size(),
}
@app.post("/webhook")
async def webhook(
request: Request,
x_nextcloud_talk_random: Optional[str] = Header(None),
x_nextcloud_talk_signature: Optional[str] = Header(None),
):
body = await request.body()
if not verify_webhook_backend(
x_nextcloud_talk_random,
x_nextcloud_talk_signature,
body,
):
raise HTTPException(
status_code=401,
detail="Invalid webhook signature",
)
try:
payload = json.loads(body)
except json.JSONDecodeError:
raise HTTPException(
status_code=400,
detail="Invalid JSON",
)
event_type = payload.get("type")
if event_type not in {"Create", "Activity"}:
return JSONResponse(
{
"status": "ignored",
"reason": f"unsupported event type: {event_type}",
}
)
actor = payload.get("actor", {})
obj = payload.get("object", {})
target = payload.get("target", {})
token = target.get("id")
message_id = obj.get("id")
if not token or not message_id:
return JSONResponse(
{
"status": "ignored",
"reason": "missing token or message id",
}
)
sender_user_id = actor_user_id(actor)
sender_display_name = (
actor.get("name")
or sender_user_id
or "User"
)
log.info(
"Incoming message %s from %s in conversation %s",
message_id,
sender_user_id,
token,
)
if sender_user_id == MANAGER_USER:
log.info(
"Ignoring Manager's own message %s",
message_id,
)
return JSONResponse(
{
"status": "ignored",
"reason": "manager's own message",
}
)
content_raw = obj.get(
"content",
"{}",
)
try:
content = json.loads(content_raw)
except (json.JSONDecodeError, TypeError):
content = {}
message_text = (
content.get("message", "")
or ""
).strip()
parameters = content.get(
"parameters",
{},
)
file_param = extract_file_param(
parameters,
)
if not file_param and not message_text:
log.info(
"Message %s has no text or attachment",
message_id,
)
return JSONResponse(
{
"status": "ignored",
"reason": "empty message",
}
)
enqueue_event(
{
"token": token,
"message_id": int(message_id),
"sender_user_id": sender_user_id,
"sender_display_name": sender_display_name,
"file_param": file_param,
"message_text": message_text,
}
)
log.info(
"Queued message %s (%s pending)",
message_id,
queue_size(),
)
return JSONResponse(
{
"status": "queued",
"type": "voice" if file_param else "text",
}
)