Increase platform abstraction cohesion
This commit is contained in:
@@ -52,7 +52,7 @@ from core.models import (
|
||||
WorkspaceConversation,
|
||||
)
|
||||
from core.presence import get_settings as get_availability_settings
|
||||
from core.presence import spans_for_range
|
||||
from core.presence import latest_state_for_people, spans_for_range
|
||||
from core.realtime.typing_state import get_person_typing_state
|
||||
from core.transports.capabilities import supports, unsupported_reason
|
||||
from core.translation.engine import process_inbound_translation
|
||||
@@ -190,6 +190,92 @@ def _serialize_availability_spans(spans):
|
||||
return rows
|
||||
|
||||
|
||||
def _availability_summary_for_person(*, user, person: Person, service: str) -> dict:
|
||||
person_key = str(person.id)
|
||||
selected_service = str(service or "").strip().lower()
|
||||
state_map = latest_state_for_people(
|
||||
user=user,
|
||||
person_ids=[person_key],
|
||||
service=selected_service,
|
||||
)
|
||||
row = state_map.get(person_key)
|
||||
is_cross_service = False
|
||||
if row is None and selected_service:
|
||||
state_map = latest_state_for_people(
|
||||
user=user,
|
||||
person_ids=[person_key],
|
||||
service="",
|
||||
)
|
||||
row = state_map.get(person_key)
|
||||
is_cross_service = row is not None
|
||||
if row is None:
|
||||
return {}
|
||||
|
||||
ts_value = int(row.get("ts") or 0)
|
||||
state_value = str(row.get("state") or "unknown").strip().lower() or "unknown"
|
||||
return {
|
||||
"state": state_value,
|
||||
"state_label": state_value.title(),
|
||||
"service": str(row.get("service") or selected_service or "").strip().lower(),
|
||||
"confidence": float(row.get("confidence") or 0.0),
|
||||
"source_kind": str(row.get("source_kind") or "").strip(),
|
||||
"ts": ts_value,
|
||||
"ts_label": _format_ts_label(ts_value) if ts_value > 0 else "",
|
||||
"is_cross_service": bool(is_cross_service),
|
||||
}
|
||||
|
||||
|
||||
def _compose_availability_payload(
|
||||
*,
|
||||
user,
|
||||
person: Person | None,
|
||||
service: str,
|
||||
range_start: int,
|
||||
range_end: int,
|
||||
) -> tuple[bool, list[dict], dict]:
|
||||
settings_row = get_availability_settings(user)
|
||||
if (
|
||||
person is None
|
||||
or not settings_row.enabled
|
||||
or not settings_row.show_in_chat
|
||||
):
|
||||
return False, [], {}
|
||||
|
||||
service_key = str(service or "").strip().lower()
|
||||
rows = _serialize_availability_spans(
|
||||
spans_for_range(
|
||||
user=user,
|
||||
person=person,
|
||||
start_ts=int(range_start or 0),
|
||||
end_ts=int(range_end or 0),
|
||||
service=service_key,
|
||||
limit=200,
|
||||
)
|
||||
)
|
||||
used_cross_service = False
|
||||
if not rows and service_key:
|
||||
rows = _serialize_availability_spans(
|
||||
spans_for_range(
|
||||
user=user,
|
||||
person=person,
|
||||
start_ts=int(range_start or 0),
|
||||
end_ts=int(range_end or 0),
|
||||
service="",
|
||||
limit=200,
|
||||
)
|
||||
)
|
||||
used_cross_service = bool(rows)
|
||||
|
||||
summary = _availability_summary_for_person(
|
||||
user=user,
|
||||
person=person,
|
||||
service=service_key,
|
||||
)
|
||||
if used_cross_service and summary:
|
||||
summary["is_cross_service"] = True
|
||||
return True, rows, summary
|
||||
|
||||
|
||||
def _is_outgoing(msg: Message) -> bool:
|
||||
is_outgoing = str(msg.custom_author or "").upper() in {"USER", "BOT"}
|
||||
if not is_outgoing:
|
||||
@@ -507,6 +593,66 @@ def _serialize_message(msg: Message) -> dict:
|
||||
)
|
||||
# Receipt payload and metadata
|
||||
receipt_payload = msg.receipt_payload or {}
|
||||
deleted_payload = dict((receipt_payload or {}).get("deleted") or {})
|
||||
is_deleted = bool(
|
||||
(receipt_payload or {}).get("is_deleted")
|
||||
or deleted_payload
|
||||
or (receipt_payload or {}).get("delete_events")
|
||||
)
|
||||
deleted_ts = 0
|
||||
for candidate in (
|
||||
deleted_payload.get("deleted_ts"),
|
||||
deleted_payload.get("updated_at"),
|
||||
deleted_payload.get("ts"),
|
||||
):
|
||||
try:
|
||||
deleted_ts = int(candidate or 0)
|
||||
except Exception:
|
||||
deleted_ts = 0
|
||||
if deleted_ts > 0:
|
||||
break
|
||||
deleted_display = _format_ts_label(deleted_ts) if deleted_ts > 0 else ""
|
||||
deleted_actor = str(deleted_payload.get("actor") or "").strip()
|
||||
deleted_source_service = str(deleted_payload.get("source_service") or "").strip()
|
||||
|
||||
edit_history_rows = []
|
||||
for row in list((receipt_payload or {}).get("edit_history") or []):
|
||||
item = dict(row or {})
|
||||
edited_ts = 0
|
||||
for candidate in (
|
||||
item.get("edited_ts"),
|
||||
item.get("updated_at"),
|
||||
item.get("ts"),
|
||||
):
|
||||
try:
|
||||
edited_ts = int(candidate or 0)
|
||||
except Exception:
|
||||
edited_ts = 0
|
||||
if edited_ts > 0:
|
||||
break
|
||||
previous_text = str(item.get("previous_text") or "")
|
||||
new_text = str(item.get("new_text") or "")
|
||||
edit_history_rows.append(
|
||||
{
|
||||
"edited_ts": edited_ts,
|
||||
"edited_display": _format_ts_label(edited_ts) if edited_ts > 0 else "",
|
||||
"source_service": str(item.get("source_service") or "").strip().lower(),
|
||||
"actor": str(item.get("actor") or "").strip(),
|
||||
"previous_text": previous_text,
|
||||
"new_text": new_text,
|
||||
}
|
||||
)
|
||||
edit_history_rows.sort(key=lambda row: int(row.get("edited_ts") or 0))
|
||||
edit_count = len(edit_history_rows)
|
||||
last_edit_ts = int(edit_history_rows[-1].get("edited_ts") or 0) if edit_count else 0
|
||||
last_edit_display = _format_ts_label(last_edit_ts) if last_edit_ts > 0 else ""
|
||||
|
||||
if is_deleted:
|
||||
display_text = "(message deleted)"
|
||||
image_urls = []
|
||||
image_url = ""
|
||||
hide_text = False
|
||||
|
||||
read_source_service = str(msg.read_source_service or "").strip()
|
||||
read_by_identifier = str(msg.read_by_identifier or "").strip()
|
||||
reaction_rows = []
|
||||
@@ -570,6 +716,17 @@ def _serialize_message(msg: Message) -> dict:
|
||||
"receipt_payload": receipt_payload,
|
||||
"read_source_service": read_source_service,
|
||||
"read_by_identifier": read_by_identifier,
|
||||
"is_deleted": is_deleted,
|
||||
"deleted_ts": deleted_ts,
|
||||
"deleted_display": deleted_display,
|
||||
"deleted_actor": deleted_actor,
|
||||
"deleted_source_service": deleted_source_service,
|
||||
"edit_history": edit_history_rows,
|
||||
"edit_history_json": json.dumps(edit_history_rows),
|
||||
"edit_count": edit_count,
|
||||
"is_edited": bool(edit_count),
|
||||
"last_edit_ts": last_edit_ts,
|
||||
"last_edit_display": last_edit_display,
|
||||
"reactions": reaction_rows,
|
||||
"source_message_id": str(getattr(msg, "source_message_id", "") or ""),
|
||||
"reply_to_id": str(getattr(msg, "reply_to_id", "") or ""),
|
||||
@@ -2694,35 +2851,27 @@ def _panel_context(
|
||||
counterpart_identifiers=counterpart_identifiers,
|
||||
conversation=conversation,
|
||||
)
|
||||
availability_slices = []
|
||||
availability_enabled = False
|
||||
availability_settings = get_availability_settings(request.user)
|
||||
if (
|
||||
base["person"] is not None
|
||||
and availability_settings.enabled
|
||||
and availability_settings.show_in_chat
|
||||
):
|
||||
range_start = (
|
||||
int(session_bundle["messages"][0].ts or 0) if session_bundle["messages"] else 0
|
||||
)
|
||||
range_end = (
|
||||
int(session_bundle["messages"][-1].ts or 0) if session_bundle["messages"] else 0
|
||||
)
|
||||
if range_start <= 0 or range_end <= 0:
|
||||
now_ts = int(time.time() * 1000)
|
||||
range_start = now_ts - (24 * 60 * 60 * 1000)
|
||||
range_end = now_ts
|
||||
availability_enabled = True
|
||||
availability_slices = _serialize_availability_spans(
|
||||
spans_for_range(
|
||||
user=request.user,
|
||||
person=base["person"],
|
||||
start_ts=range_start,
|
||||
end_ts=range_end,
|
||||
service=base["service"],
|
||||
limit=200,
|
||||
)
|
||||
)
|
||||
range_start = (
|
||||
int(session_bundle["messages"][0].ts or 0) if session_bundle["messages"] else 0
|
||||
)
|
||||
range_end = (
|
||||
int(session_bundle["messages"][-1].ts or 0) if session_bundle["messages"] else 0
|
||||
)
|
||||
if range_start <= 0 or range_end <= 0:
|
||||
now_ts = int(time.time() * 1000)
|
||||
range_start = now_ts - (24 * 60 * 60 * 1000)
|
||||
range_end = now_ts
|
||||
(
|
||||
availability_enabled,
|
||||
availability_slices,
|
||||
availability_summary,
|
||||
) = _compose_availability_payload(
|
||||
user=request.user,
|
||||
person=base["person"],
|
||||
service=base["service"],
|
||||
range_start=range_start,
|
||||
range_end=range_end,
|
||||
)
|
||||
glance_items = _build_glance_items(
|
||||
serialized_messages,
|
||||
person_id=(base["person"].id if base["person"] else None),
|
||||
@@ -2923,9 +3072,15 @@ def _panel_context(
|
||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||
"panel_id": f"compose-panel-{unique}",
|
||||
"typing_state_json": json.dumps(typing_state),
|
||||
"capability_send": supports(base["service"], "send"),
|
||||
"capability_send_reason": unsupported_reason(base["service"], "send"),
|
||||
"capability_reactions": supports(base["service"], "reactions"),
|
||||
"capability_reactions_reason": unsupported_reason(base["service"], "reactions"),
|
||||
"availability_enabled": availability_enabled,
|
||||
"availability_slices": availability_slices,
|
||||
"availability_slices_json": json.dumps(availability_slices),
|
||||
"availability_summary": availability_summary,
|
||||
"availability_summary_json": json.dumps(availability_summary),
|
||||
"command_options": command_options,
|
||||
"bp_binding_summary": bp_binding_summary,
|
||||
"platform_options": platform_options,
|
||||
@@ -3383,31 +3538,23 @@ class ComposeThread(LoginRequiredMixin, View):
|
||||
counterpart_identifiers = _counterpart_identifiers_for_person(
|
||||
request.user, base["person"]
|
||||
)
|
||||
availability_slices = []
|
||||
availability_settings = get_availability_settings(request.user)
|
||||
if (
|
||||
base["person"] is not None
|
||||
and availability_settings.enabled
|
||||
and availability_settings.show_in_chat
|
||||
):
|
||||
range_start = (
|
||||
int(messages[0].ts or 0) if messages else max(0, int(after_ts or 0))
|
||||
)
|
||||
range_end = int(latest_ts or 0)
|
||||
if range_start <= 0 or range_end <= 0:
|
||||
now_ts = int(time.time() * 1000)
|
||||
range_start = now_ts - (24 * 60 * 60 * 1000)
|
||||
range_end = now_ts
|
||||
availability_slices = _serialize_availability_spans(
|
||||
spans_for_range(
|
||||
user=request.user,
|
||||
person=base["person"],
|
||||
start_ts=range_start,
|
||||
end_ts=range_end,
|
||||
service=base["service"],
|
||||
limit=200,
|
||||
)
|
||||
)
|
||||
range_start = int(messages[0].ts or 0) if messages else max(0, int(after_ts or 0))
|
||||
range_end = int(latest_ts or 0)
|
||||
if range_start <= 0 or range_end <= 0:
|
||||
now_ts = int(time.time() * 1000)
|
||||
range_start = now_ts - (24 * 60 * 60 * 1000)
|
||||
range_end = now_ts
|
||||
(
|
||||
_availability_enabled,
|
||||
availability_slices,
|
||||
availability_summary,
|
||||
) = _compose_availability_payload(
|
||||
user=request.user,
|
||||
person=base["person"],
|
||||
service=base["service"],
|
||||
range_start=range_start,
|
||||
range_end=range_end,
|
||||
)
|
||||
payload = {
|
||||
"messages": _serialize_messages_with_artifacts(
|
||||
messages,
|
||||
@@ -3417,6 +3564,7 @@ class ComposeThread(LoginRequiredMixin, View):
|
||||
),
|
||||
"last_ts": latest_ts,
|
||||
"availability_slices": availability_slices,
|
||||
"availability_summary": availability_summary,
|
||||
"typing": get_person_typing_state(
|
||||
user_id=request.user.id,
|
||||
person_id=base["person"].id if base["person"] else None,
|
||||
@@ -4459,6 +4607,18 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
log_prefix = (
|
||||
f"[ComposeSend] service={base['service']} identifier={base['identifier']}"
|
||||
)
|
||||
if bool(getattr(settings, "CAPABILITY_ENFORCEMENT_ENABLED", True)) and not supports(
|
||||
str(base["service"] or "").strip().lower(),
|
||||
"send",
|
||||
):
|
||||
reason = unsupported_reason(str(base["service"] or "").strip().lower(), "send")
|
||||
return self._response(
|
||||
request,
|
||||
ok=False,
|
||||
message=f"Send not supported: {reason}",
|
||||
level="warning",
|
||||
panel_id=panel_id,
|
||||
)
|
||||
logger.debug(f"{log_prefix} text_len={len(text)} attempting send")
|
||||
|
||||
# If runtime is out-of-process, enqueue command and return immediately (non-blocking).
|
||||
|
||||
88
core/views/prosody.py
Normal file
88
core/views/prosody.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate, get_user_model
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.views import View
|
||||
|
||||
|
||||
class ProsodyAuthBridge(View):
|
||||
"""
|
||||
Minimal external-auth bridge for Prosody.
|
||||
Returns plain text "1" or "0" per Prosody external auth protocol.
|
||||
"""
|
||||
|
||||
http_method_names = ["get", "post"]
|
||||
|
||||
def _denied(self) -> HttpResponse:
|
||||
return HttpResponse("0\n", content_type="text/plain")
|
||||
|
||||
def _b64url_decode(self, value: str) -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
padded = raw + "=" * (-len(raw) % 4)
|
||||
padded = padded.replace("-", "+").replace("_", "/")
|
||||
try:
|
||||
return base64.b64decode(padded.encode("ascii")).decode(
|
||||
"utf-8", errors="ignore"
|
||||
)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _extract_line(self, request: HttpRequest) -> str:
|
||||
line_b64 = str(request.GET.get("line_b64") or "").strip()
|
||||
if line_b64:
|
||||
return self._b64url_decode(line_b64)
|
||||
body = (request.body or b"").decode("utf-8", errors="ignore").strip()
|
||||
if body:
|
||||
return body
|
||||
return str(request.POST.get("line") or "").strip()
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
remote_addr = str(request.META.get("REMOTE_ADDR") or "").strip()
|
||||
if remote_addr not in {"127.0.0.1", "::1"}:
|
||||
return self._denied()
|
||||
|
||||
expected_secret = str(getattr(settings, "XMPP_SECRET", "") or "").strip()
|
||||
supplied_secret = str(request.headers.get("X-Prosody-Secret") or "").strip()
|
||||
if not supplied_secret:
|
||||
supplied_secret = str(request.GET.get("secret") or "").strip()
|
||||
secret_b64 = str(request.GET.get("secret_b64") or "").strip()
|
||||
if not supplied_secret and secret_b64:
|
||||
supplied_secret = self._b64url_decode(secret_b64)
|
||||
if not expected_secret or supplied_secret != expected_secret:
|
||||
return self._denied()
|
||||
|
||||
line = self._extract_line(request)
|
||||
if not line:
|
||||
return self._denied()
|
||||
|
||||
parts = line.split(":")
|
||||
if len(parts) < 3:
|
||||
return self._denied()
|
||||
|
||||
command, username, _domain = parts[:3]
|
||||
password = ":".join(parts[3:]) if len(parts) > 3 else None
|
||||
|
||||
if command == "auth":
|
||||
if not password:
|
||||
return self._denied()
|
||||
user = authenticate(username=username, password=password)
|
||||
ok = bool(user is not None and getattr(user, "is_active", False))
|
||||
return HttpResponse("1\n" if ok else "0\n", content_type="text/plain")
|
||||
|
||||
if command == "isuser":
|
||||
User = get_user_model()
|
||||
exists = bool(User.objects.filter(username=username).exists())
|
||||
return HttpResponse("1\n" if exists else "0\n", content_type="text/plain")
|
||||
|
||||
if command == "setpass":
|
||||
return self._denied()
|
||||
|
||||
return self._denied()
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
return self.post(request)
|
||||
@@ -14,6 +14,7 @@ from django.views import View
|
||||
|
||||
from core.forms import AIWorkspaceWindowForm
|
||||
from core.lib.notify import raw_sendmsg
|
||||
from core.memory.retrieval import retrieve_memories_for_prompt
|
||||
from core.messaging import ai as ai_runner
|
||||
from core.messaging.utils import messages_to_string
|
||||
from core.models import (
|
||||
@@ -3936,8 +3937,27 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View):
|
||||
)
|
||||
return rows
|
||||
|
||||
def _build_prompt(self, operation, owner_name, person, transcript, user_notes):
|
||||
def _build_prompt(
|
||||
self,
|
||||
operation,
|
||||
owner_name,
|
||||
person,
|
||||
transcript,
|
||||
user_notes,
|
||||
memory_context,
|
||||
):
|
||||
notes = (user_notes or "").strip()
|
||||
memory_lines = []
|
||||
for index, item in enumerate(memory_context or [], start=1):
|
||||
content = item.get("content") or {}
|
||||
text = str(content.get("text") or "").strip()
|
||||
if not text:
|
||||
text = str(content).strip()
|
||||
if not text:
|
||||
continue
|
||||
kind = str(item.get("memory_kind") or "fact")
|
||||
memory_lines.append(f"{index}. [{kind}] {text}")
|
||||
memory_text = "\n".join(memory_lines) if memory_lines else "None"
|
||||
if operation == "draft_reply":
|
||||
instruction = (
|
||||
"Generate 3 concise reply options in different tones: soft, neutral, firm. "
|
||||
@@ -3965,6 +3985,8 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View):
|
||||
f"Owner: {owner_name}\n"
|
||||
f"Person: {person.name}\n"
|
||||
f"Notes: {notes or 'None'}\n\n"
|
||||
"Approved Memory Context:\n"
|
||||
f"{memory_text}\n\n"
|
||||
f"Conversation:\n{transcript}"
|
||||
),
|
||||
},
|
||||
@@ -4111,12 +4133,20 @@ class AIWorkspaceRunOperation(LoginRequiredMixin, View):
|
||||
)
|
||||
|
||||
try:
|
||||
memory_context = retrieve_memories_for_prompt(
|
||||
user_id=request.user.id,
|
||||
person_id=str(person.id),
|
||||
conversation_id=str(conversation.id),
|
||||
statuses=("active",),
|
||||
limit=12,
|
||||
)
|
||||
prompt = self._build_prompt(
|
||||
operation=operation,
|
||||
owner_name=owner_name,
|
||||
person=person,
|
||||
transcript=transcript,
|
||||
user_notes=user_notes,
|
||||
memory_context=memory_context,
|
||||
)
|
||||
result_text = async_to_sync(ai_runner.run_prompt)(prompt, ai_obj)
|
||||
draft_options = (
|
||||
|
||||
Reference in New Issue
Block a user