Increase platform abstraction cohesion

This commit is contained in:
2026-03-06 17:47:58 +00:00
parent 438e561da0
commit 8c091b1e6d
55 changed files with 6555 additions and 440 deletions

View File

@@ -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
View 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)

View File

@@ -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 = (