1337 строки
30 KiB
Python
1337 строки
30 KiB
Python
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import threading
|
|
from datetime import datetime, 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",
|
|
)
|
|
|
|
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
|
|
)
|
|
"""
|
|
)
|
|
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]]:
|
|
with sqlite3.connect(QUEUE_DB_PATH, timeout=30) as conn:
|
|
row = conn.execute(
|
|
"SELECT id, payload FROM queue ORDER BY id LIMIT 1"
|
|
).fetchone()
|
|
|
|
if not row:
|
|
return None
|
|
|
|
row_id, payload = row
|
|
return row_id, json.loads(payload)
|
|
|
|
|
|
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 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),
|
|
)
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
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
|
|
|
|
log.info(
|
|
"Message %s has no text or attachment",
|
|
message_id,
|
|
)
|
|
|
|
|
|
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 = job_row
|
|
|
|
try:
|
|
process_event(job)
|
|
except Exception as exc:
|
|
log.exception(
|
|
"Unhandled error processing message %s: %s",
|
|
job.get("message_id"),
|
|
exc,
|
|
)
|
|
finally:
|
|
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",
|
|
}
|
|
)
|