Files
bdi_podman_serverconf/containers/talkbot/server.py
T

1192 baris
26 KiB
Python

import json
import logging
import os
import re
from datetime import datetime
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",
)
MANAGER_USER = NC_USER
app = FastAPI(title="Nextcloud Talk voice translator")
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 transcribe(audio_bytes: bytes) -> tuple[str, str]:
response = httpx.post(
f"{WHISPER_URL}/inference",
files={
"file": (
"audio.wav",
audio_bytes,
"audio/wav",
)
},
data={
"response_format": "json",
},
timeout=300,
)
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,
translated_text,
)
log.info(
"Posted translated audio %s (%s)",
filename,
language_name(target_language),
)
@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,
}
@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,
)
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=int(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 JSONResponse(
{
"status": "ok",
"type": "voice",
}
)
if message_text:
log.info(
"Text message %s from %s",
message_id,
sender_user_id,
)
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
post_manager_message(
token,
translated,
)
log.info(
"Posted text translation for %s: %s",
recipient,
target_language,
)
return JSONResponse(
{
"status": "ok",
"type": "text",
}
)
log.info(
"Message %s has no text or attachment",
message_id,
)
return JSONResponse(
{
"status": "ignored",
"reason": "empty message",
}
)