Continue AI features and improve protocol support
This commit is contained in:
@@ -48,6 +48,13 @@ if DEBUG:
|
||||
SETTINGS_EXPORT = ["BILLING_ENABLED"]
|
||||
|
||||
SIGNAL_NUMBER = getenv("SIGNAL_NUMBER")
|
||||
SIGNAL_HTTP_URL = getenv("SIGNAL_HTTP_URL", "http://signal:8080")
|
||||
|
||||
WHATSAPP_ENABLED = getenv("WHATSAPP_ENABLED", "false").lower() in trues
|
||||
WHATSAPP_HTTP_URL = getenv("WHATSAPP_HTTP_URL", "http://whatsapp:8080")
|
||||
|
||||
INSTAGRAM_ENABLED = getenv("INSTAGRAM_ENABLED", "false").lower() in trues
|
||||
INSTAGRAM_HTTP_URL = getenv("INSTAGRAM_HTTP_URL", "http://instagram:8080")
|
||||
|
||||
XMPP_ADDRESS = getenv("XMPP_ADDRESS")
|
||||
XMPP_JID = getenv("XMPP_JID")
|
||||
|
||||
107
app/urls.py
107
app/urls.py
@@ -18,14 +18,15 @@ from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.views import LogoutView
|
||||
from django.urls import include, path
|
||||
from django.views.generic import TemplateView
|
||||
from two_factor.urls import urlpatterns as tf_urls
|
||||
|
||||
from core.views import (
|
||||
ais,
|
||||
base,
|
||||
compose,
|
||||
groups,
|
||||
identifiers,
|
||||
instagram,
|
||||
manipulations,
|
||||
messages,
|
||||
notifications,
|
||||
@@ -34,6 +35,7 @@ from core.views import (
|
||||
queues,
|
||||
sessions,
|
||||
signal,
|
||||
whatsapp,
|
||||
workspace,
|
||||
)
|
||||
|
||||
@@ -56,11 +58,31 @@ urlpatterns = [
|
||||
signal.Signal.as_view(),
|
||||
name="signal",
|
||||
),
|
||||
path(
|
||||
"services/whatsapp/",
|
||||
whatsapp.WhatsApp.as_view(),
|
||||
name="whatsapp",
|
||||
),
|
||||
path(
|
||||
"services/instagram/",
|
||||
instagram.Instagram.as_view(),
|
||||
name="instagram",
|
||||
),
|
||||
path(
|
||||
"services/signal/<str:type>/",
|
||||
signal.SignalAccounts.as_view(),
|
||||
name="signal_accounts",
|
||||
),
|
||||
path(
|
||||
"services/whatsapp/<str:type>/",
|
||||
whatsapp.WhatsAppAccounts.as_view(),
|
||||
name="whatsapp_accounts",
|
||||
),
|
||||
path(
|
||||
"services/instagram/<str:type>/",
|
||||
instagram.InstagramAccounts.as_view(),
|
||||
name="instagram_accounts",
|
||||
),
|
||||
path(
|
||||
"services/signal/<str:type>/contacts/<str:pk>/",
|
||||
signal.SignalContactsList.as_view(),
|
||||
@@ -81,6 +103,41 @@ urlpatterns = [
|
||||
signal.SignalAccountAdd.as_view(),
|
||||
name="signal_account_add",
|
||||
),
|
||||
path(
|
||||
"services/whatsapp/<str:type>/add/",
|
||||
whatsapp.WhatsAppAccountAdd.as_view(),
|
||||
name="whatsapp_account_add",
|
||||
),
|
||||
path(
|
||||
"services/instagram/<str:type>/add/",
|
||||
instagram.InstagramAccountAdd.as_view(),
|
||||
name="instagram_account_add",
|
||||
),
|
||||
path(
|
||||
"compose/page/",
|
||||
compose.ComposePage.as_view(),
|
||||
name="compose_page",
|
||||
),
|
||||
path(
|
||||
"compose/widget/",
|
||||
compose.ComposeWidget.as_view(),
|
||||
name="compose_widget",
|
||||
),
|
||||
path(
|
||||
"compose/send/",
|
||||
compose.ComposeSend.as_view(),
|
||||
name="compose_send",
|
||||
),
|
||||
path(
|
||||
"compose/thread/",
|
||||
compose.ComposeThread.as_view(),
|
||||
name="compose_thread",
|
||||
),
|
||||
path(
|
||||
"compose/widget/contacts/",
|
||||
compose.ComposeContactsDropdown.as_view(),
|
||||
name="compose_contacts_dropdown",
|
||||
),
|
||||
# AIs
|
||||
path(
|
||||
"ai/workspace/",
|
||||
@@ -97,6 +154,21 @@ urlpatterns = [
|
||||
workspace.AIWorkspacePersonWidget.as_view(),
|
||||
name="ai_workspace_person",
|
||||
),
|
||||
path(
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/insights/graphs/",
|
||||
workspace.AIWorkspaceInsightGraphs.as_view(),
|
||||
name="ai_workspace_insight_graphs",
|
||||
),
|
||||
path(
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/insights/help/",
|
||||
workspace.AIWorkspaceInsightHelp.as_view(),
|
||||
name="ai_workspace_insight_help",
|
||||
),
|
||||
path(
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/insights/<str:metric>/",
|
||||
workspace.AIWorkspaceInsightDetail.as_view(),
|
||||
name="ai_workspace_insight_detail",
|
||||
),
|
||||
path(
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/run/<str:operation>/",
|
||||
workspace.AIWorkspaceRunOperation.as_view(),
|
||||
@@ -118,50 +190,65 @@ urlpatterns = [
|
||||
name="ai_workspace_mitigation_create",
|
||||
),
|
||||
path(
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/chat/",
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
|
||||
"<uuid:plan_id>/chat/",
|
||||
workspace.AIWorkspaceMitigationChat.as_view(),
|
||||
name="ai_workspace_mitigation_chat",
|
||||
),
|
||||
path(
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/export/",
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
|
||||
"<uuid:plan_id>/export/",
|
||||
workspace.AIWorkspaceExportArtifact.as_view(),
|
||||
name="ai_workspace_mitigation_export",
|
||||
),
|
||||
path(
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/artifact/create/<str:kind>/",
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
|
||||
"<uuid:plan_id>/artifact/create/<str:kind>/",
|
||||
workspace.AIWorkspaceCreateArtifact.as_view(),
|
||||
name="ai_workspace_mitigation_artifact_create",
|
||||
),
|
||||
path(
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/artifact/<str:kind>/<uuid:artifact_id>/save/",
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
|
||||
"<uuid:plan_id>/artifact/<str:kind>/<uuid:artifact_id>/save/",
|
||||
workspace.AIWorkspaceUpdateArtifact.as_view(),
|
||||
name="ai_workspace_mitigation_artifact_save",
|
||||
),
|
||||
path(
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/artifact/<str:kind>/<uuid:artifact_id>/delete/",
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
|
||||
"<uuid:plan_id>/artifact/<str:kind>/<uuid:artifact_id>/delete/",
|
||||
workspace.AIWorkspaceDeleteArtifact.as_view(),
|
||||
name="ai_workspace_mitigation_artifact_delete",
|
||||
),
|
||||
path(
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/artifact/<str:kind>/delete-all/",
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
|
||||
"<uuid:plan_id>/artifact/<str:kind>/delete-all/",
|
||||
workspace.AIWorkspaceDeleteArtifactList.as_view(),
|
||||
name="ai_workspace_mitigation_artifact_delete_all",
|
||||
),
|
||||
path(
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/engage/share/",
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
|
||||
"<uuid:plan_id>/engage/share/",
|
||||
workspace.AIWorkspaceEngageShare.as_view(),
|
||||
name="ai_workspace_mitigation_engage_share",
|
||||
),
|
||||
path(
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/auto/",
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
|
||||
"<uuid:plan_id>/auto/",
|
||||
workspace.AIWorkspaceAutoSettings.as_view(),
|
||||
name="ai_workspace_mitigation_auto",
|
||||
),
|
||||
path(
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/fundamentals/save/",
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
|
||||
"<uuid:plan_id>/fundamentals/save/",
|
||||
workspace.AIWorkspaceUpdateFundamentals.as_view(),
|
||||
name="ai_workspace_mitigation_fundamentals_save",
|
||||
),
|
||||
path(
|
||||
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/"
|
||||
"<uuid:plan_id>/meta/save/",
|
||||
workspace.AIWorkspaceUpdatePlanMeta.as_view(),
|
||||
name="ai_workspace_mitigation_meta_save",
|
||||
),
|
||||
path(
|
||||
"ai/<str:type>/",
|
||||
ais.AIList.as_view(),
|
||||
|
||||
@@ -17,8 +17,8 @@ def log(data):
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") # Adjust if needed
|
||||
django.setup()
|
||||
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth import authenticate # noqa: E402
|
||||
from django.contrib.auth.models import User # noqa: E402
|
||||
|
||||
|
||||
def check_credentials(username, password):
|
||||
@@ -52,11 +52,11 @@ def main():
|
||||
|
||||
if command == "auth":
|
||||
if password and check_credentials(username, password):
|
||||
log(f"Authentication success")
|
||||
log("Authentication success")
|
||||
log("Sent 1")
|
||||
print("1", flush=True) # Success
|
||||
else:
|
||||
log(f"Authentication failure")
|
||||
log("Authentication failure")
|
||||
log("Sent 0")
|
||||
print("0", flush=True) # Failure
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
|
||||
# import stripe
|
||||
from django.conf import settings
|
||||
|
||||
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
|
||||
# from redis import StrictRedis
|
||||
|
||||
@@ -21,19 +21,19 @@ class ClientBase(ABC):
|
||||
# ...
|
||||
|
||||
async def message_received(self, *args, **kwargs):
|
||||
self.ur.message_received(self.service, *args, **kwargs)
|
||||
await self.ur.message_received(self.service, *args, **kwargs)
|
||||
|
||||
async def message_read(self, *args, **kwargs):
|
||||
self.ur.message_read(self.service, *args, **kwargs)
|
||||
await self.ur.message_read(self.service, *args, **kwargs)
|
||||
|
||||
async def started_typing(self, *args, **kwargs):
|
||||
self.ur.started_typing(self.service, *args, **kwargs)
|
||||
await self.ur.started_typing(self.service, *args, **kwargs)
|
||||
|
||||
async def stopped_typing(self, *args, **kwargs):
|
||||
self.ur.stopped_typing(self.service, *args, **kwargs)
|
||||
await self.ur.stopped_typing(self.service, *args, **kwargs)
|
||||
|
||||
async def reacted(self, *args, **kwargs):
|
||||
self.ur.reacted(self.service, *args, **kwargs)
|
||||
await self.ur.reacted(self.service, *args, **kwargs)
|
||||
|
||||
async def replied(self, *args, **kwargs):
|
||||
self.ur.replied(self.service, *args, **kwargs)
|
||||
await self.ur.replied(self.service, *args, **kwargs)
|
||||
|
||||
185
core/clients/gateway.py
Normal file
185
core/clients/gateway.py
Normal file
@@ -0,0 +1,185 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import aiohttp
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
|
||||
from core.clients import ClientBase, transport
|
||||
from core.messaging import history
|
||||
from core.modules.mixed_protocol import normalize_gateway_event
|
||||
from core.models import PersonIdentifier
|
||||
|
||||
|
||||
class GatewayClient(ClientBase):
|
||||
"""
|
||||
Generic gateway-backed client for mixed protocol services.
|
||||
|
||||
Expected gateway contract:
|
||||
- GET /v1/events/next -> JSON event (or 204 for no events)
|
||||
- POST /v1/send
|
||||
- POST /v1/typing/start
|
||||
- POST /v1/typing/stop
|
||||
"""
|
||||
|
||||
poll_interval_seconds = 1
|
||||
|
||||
def __init__(self, ur, loop, service):
|
||||
super().__init__(ur, loop, service)
|
||||
self._task = None
|
||||
self._stopping = False
|
||||
self._not_found_count = 0
|
||||
self.base_url = str(
|
||||
getattr(
|
||||
settings,
|
||||
f"{service.upper()}_HTTP_URL",
|
||||
f"http://{service}:8080",
|
||||
)
|
||||
).rstrip("/")
|
||||
self.enabled = bool(
|
||||
str(
|
||||
getattr(settings, f"{service.upper()}_ENABLED", "true"),
|
||||
).lower()
|
||||
in {"1", "true", "yes", "on"}
|
||||
)
|
||||
|
||||
def start(self):
|
||||
if not self.enabled:
|
||||
self.log.info("%s gateway disabled by settings", self.service)
|
||||
return
|
||||
if self._task is None:
|
||||
self.log.info("%s gateway client starting (%s)", self.service, self.base_url)
|
||||
self._task = self.loop.create_task(self._poll_loop())
|
||||
|
||||
async def start_typing(self, identifier):
|
||||
return await transport.start_typing(self.service, identifier)
|
||||
|
||||
async def stop_typing(self, identifier):
|
||||
return await transport.stop_typing(self.service, identifier)
|
||||
|
||||
async def _gateway_next_event(self):
|
||||
url = f"{self.base_url}/v1/events/next"
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url) as response:
|
||||
if response.status == 204:
|
||||
return "empty", None
|
||||
if response.status == 404:
|
||||
return "not_found", None
|
||||
if response.status != 200:
|
||||
return "error", None
|
||||
try:
|
||||
return "ok", await response.json()
|
||||
except Exception:
|
||||
return "error", None
|
||||
|
||||
async def _poll_loop(self):
|
||||
while not self._stopping:
|
||||
try:
|
||||
status, event = await self._gateway_next_event()
|
||||
if status == "ok" and event:
|
||||
self._not_found_count = 0
|
||||
await self._handle_event(event)
|
||||
elif status == "not_found":
|
||||
self._not_found_count += 1
|
||||
if self._not_found_count >= 3:
|
||||
self.log.warning(
|
||||
"%s gateway endpoint /v1/events/next returned 404 repeatedly; stopping client. "
|
||||
"Set %s_ENABLED=false or configure %s_HTTP_URL.",
|
||||
self.service,
|
||||
self.service.upper(),
|
||||
self.service.upper(),
|
||||
)
|
||||
self._stopping = True
|
||||
break
|
||||
await asyncio.sleep(self.poll_interval_seconds)
|
||||
elif status in {"empty", "error"}:
|
||||
await asyncio.sleep(self.poll_interval_seconds)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.log.warning("%s gateway poll error: %s", self.service, exc)
|
||||
await asyncio.sleep(max(2, self.poll_interval_seconds))
|
||||
|
||||
async def _handle_event(self, event):
|
||||
normalized = normalize_gateway_event(self.service, event)
|
||||
event_type = normalized.event_type
|
||||
if event_type == "message":
|
||||
await self._handle_message(normalized)
|
||||
return
|
||||
if event_type == "read":
|
||||
await self.ur.message_read(
|
||||
self.service,
|
||||
identifier=normalized.identifier,
|
||||
message_timestamps=normalized.message_timestamps,
|
||||
read_ts=normalized.payload.get("read_ts"),
|
||||
read_by=normalized.payload.get("read_by")
|
||||
or normalized.payload.get("reader")
|
||||
or normalized.identifier,
|
||||
payload=normalized.payload,
|
||||
)
|
||||
return
|
||||
if event_type in {"typing_start", "typing_started"}:
|
||||
await self.ur.started_typing(
|
||||
self.service,
|
||||
identifier=normalized.identifier,
|
||||
payload=normalized.payload,
|
||||
)
|
||||
return
|
||||
if event_type in {"typing_stop", "typing_stopped"}:
|
||||
await self.ur.stopped_typing(
|
||||
self.service,
|
||||
identifier=normalized.identifier,
|
||||
payload=normalized.payload,
|
||||
)
|
||||
return
|
||||
|
||||
async def _handle_message(self, event):
|
||||
identifier_value = event.identifier
|
||||
if not identifier_value:
|
||||
return
|
||||
|
||||
text = event.text
|
||||
ts = int(event.ts or int(time.time() * 1000))
|
||||
attachments = event.attachments
|
||||
identifiers = await sync_to_async(list)(
|
||||
PersonIdentifier.objects.filter(
|
||||
identifier=identifier_value,
|
||||
service=self.service,
|
||||
)
|
||||
)
|
||||
if not identifiers:
|
||||
return
|
||||
|
||||
xmpp_attachments = []
|
||||
if attachments:
|
||||
fetched = await asyncio.gather(
|
||||
*[transport.fetch_attachment(self.service, att) for att in attachments]
|
||||
)
|
||||
for row in fetched:
|
||||
if row:
|
||||
xmpp_attachments.append(row)
|
||||
|
||||
for identifier in identifiers:
|
||||
session = await history.get_chat_session(identifier.user, identifier)
|
||||
await history.store_message(
|
||||
session=session,
|
||||
sender=identifier_value,
|
||||
text=text,
|
||||
ts=ts,
|
||||
outgoing=False,
|
||||
)
|
||||
await self.ur.xmpp.client.send_from_external(
|
||||
identifier.user,
|
||||
identifier,
|
||||
text,
|
||||
is_outgoing_message=False,
|
||||
attachments=xmpp_attachments,
|
||||
)
|
||||
await self.ur.message_received(
|
||||
self.service,
|
||||
identifier=identifier,
|
||||
text=text,
|
||||
ts=ts,
|
||||
payload=event.payload,
|
||||
)
|
||||
6
core/clients/instagram.py
Normal file
6
core/clients/instagram.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from core.clients.gateway import GatewayClient
|
||||
|
||||
|
||||
class InstagramClient(GatewayClient):
|
||||
def __init__(self, ur, loop, service="instagram"):
|
||||
super().__init__(ur, loop, service)
|
||||
8
core/clients/serviceapi.py
Normal file
8
core/clients/serviceapi.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Backward-compatible compatibility layer.
|
||||
|
||||
Prefer importing from `core.clients.transport`.
|
||||
"""
|
||||
|
||||
from core.clients.transport import * # noqa: F401,F403
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
from asgiref.sync import sync_to_async
|
||||
@@ -15,12 +16,19 @@ from core.util import logs
|
||||
log = logs.get_logger("signalF")
|
||||
|
||||
|
||||
if settings.DEBUG:
|
||||
SIGNAL_HOST = "127.0.0.1"
|
||||
_signal_http_url = getattr(settings, "SIGNAL_HTTP_URL", "").strip()
|
||||
if _signal_http_url:
|
||||
parsed = urlparse(
|
||||
_signal_http_url if "://" in _signal_http_url else f"http://{_signal_http_url}"
|
||||
)
|
||||
SIGNAL_HOST = parsed.hostname or "signal"
|
||||
SIGNAL_PORT = parsed.port or 8080
|
||||
else:
|
||||
SIGNAL_HOST = "signal"
|
||||
|
||||
SIGNAL_PORT = 8080
|
||||
if settings.DEBUG:
|
||||
SIGNAL_HOST = "127.0.0.1"
|
||||
else:
|
||||
SIGNAL_HOST = "signal"
|
||||
SIGNAL_PORT = 8080
|
||||
|
||||
SIGNAL_URL = f"{SIGNAL_HOST}:{SIGNAL_PORT}"
|
||||
|
||||
@@ -103,6 +111,36 @@ def _extract_attachments(raw_payload):
|
||||
return results
|
||||
|
||||
|
||||
def _extract_receipt_timestamps(receipt_payload):
|
||||
raw_ts = receipt_payload.get("timestamp")
|
||||
if raw_ts is None:
|
||||
raw_ts = receipt_payload.get("timestamps")
|
||||
if isinstance(raw_ts, list):
|
||||
out = []
|
||||
for item in raw_ts:
|
||||
try:
|
||||
out.append(int(item))
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
if raw_ts is not None:
|
||||
try:
|
||||
return [int(raw_ts)]
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def _typing_started(typing_payload):
|
||||
action = str(typing_payload.get("action") or "").strip().lower()
|
||||
if action in {"started", "start", "typing", "composing"}:
|
||||
return True
|
||||
explicit = typing_payload.get("isTyping")
|
||||
if isinstance(explicit, bool):
|
||||
return explicit
|
||||
return True
|
||||
|
||||
|
||||
class NewSignalBot(SignalBot):
|
||||
def __init__(self, ur, service, config):
|
||||
self.ur = ur
|
||||
@@ -221,16 +259,53 @@ class HandleMessage(Command):
|
||||
log.warning("No Signal identifier available for message routing.")
|
||||
return
|
||||
|
||||
# Handle attachments across multiple Signal payload variants.
|
||||
attachment_list = _extract_attachments(raw)
|
||||
|
||||
# Get users/person identifiers for this Signal sender/recipient.
|
||||
# Resolve person identifiers once for this event.
|
||||
identifiers = await sync_to_async(list)(
|
||||
PersonIdentifier.objects.filter(
|
||||
identifier=identifier_uuid,
|
||||
service=self.service,
|
||||
)
|
||||
)
|
||||
|
||||
envelope = raw.get("envelope", {})
|
||||
typing_payload = envelope.get("typingMessage")
|
||||
if isinstance(typing_payload, dict):
|
||||
for identifier in identifiers:
|
||||
if _typing_started(typing_payload):
|
||||
await self.ur.started_typing(
|
||||
self.service,
|
||||
identifier=identifier,
|
||||
payload=typing_payload,
|
||||
)
|
||||
else:
|
||||
await self.ur.stopped_typing(
|
||||
self.service,
|
||||
identifier=identifier,
|
||||
payload=typing_payload,
|
||||
)
|
||||
return
|
||||
|
||||
receipt_payload = envelope.get("receiptMessage")
|
||||
if isinstance(receipt_payload, dict):
|
||||
read_timestamps = _extract_receipt_timestamps(receipt_payload)
|
||||
read_ts = (
|
||||
envelope.get("timestamp")
|
||||
or envelope.get("serverReceivedTimestamp")
|
||||
or c.message.timestamp
|
||||
)
|
||||
for identifier in identifiers:
|
||||
await self.ur.message_read(
|
||||
self.service,
|
||||
identifier=identifier,
|
||||
message_timestamps=read_timestamps,
|
||||
read_ts=read_ts,
|
||||
payload=receipt_payload,
|
||||
read_by=source_uuid,
|
||||
)
|
||||
return
|
||||
|
||||
# Handle attachments across multiple Signal payload variants.
|
||||
attachment_list = _extract_attachments(raw)
|
||||
xmpp_attachments = []
|
||||
|
||||
# Asynchronously fetch all attachments
|
||||
|
||||
@@ -5,7 +5,6 @@ import aiohttp
|
||||
import orjson
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from requests.exceptions import RequestException
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
@@ -57,12 +56,12 @@ async def download_and_encode_base64(file_url, filename, content_type):
|
||||
f"data:{content_type};filename={filename};base64,{base64_encoded}"
|
||||
)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
except aiohttp.ClientError:
|
||||
# log.error(f"Failed to download file: {file_url}, error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def send_message_raw(recipient_uuid, text=None, attachments=[]):
|
||||
async def send_message_raw(recipient_uuid, text=None, attachments=None):
|
||||
"""
|
||||
Sends a message using the Signal REST API, ensuring attachment links are not included in the text body.
|
||||
|
||||
@@ -84,6 +83,7 @@ async def send_message_raw(recipient_uuid, text=None, attachments=[]):
|
||||
}
|
||||
|
||||
# Asynchronously download and encode all attachments
|
||||
attachments = attachments or []
|
||||
tasks = [
|
||||
download_and_encode_base64(att["url"], att["filename"], att["content_type"])
|
||||
for att in attachments
|
||||
@@ -182,12 +182,12 @@ def download_and_encode_base64_sync(file_url, filename, content_type):
|
||||
|
||||
# Format according to Signal's expected structure
|
||||
return f"data:{content_type};filename={filename};base64,{base64_encoded}"
|
||||
except requests.RequestException as e:
|
||||
except requests.RequestException:
|
||||
# log.error(f"Failed to download file: {file_url}, error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def send_message_raw_sync(recipient_uuid, text=None, attachments=[]):
|
||||
def send_message_raw_sync(recipient_uuid, text=None, attachments=None):
|
||||
"""
|
||||
Sends a message using the Signal REST API, ensuring attachment links are not included in the text body.
|
||||
|
||||
@@ -199,13 +199,16 @@ def send_message_raw_sync(recipient_uuid, text=None, attachments=[]):
|
||||
Returns:
|
||||
int | bool: Timestamp if successful, False otherwise.
|
||||
"""
|
||||
url = "http://signal:8080/v2/send"
|
||||
base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/")
|
||||
url = f"{base}/v2/send"
|
||||
data = {
|
||||
"recipients": [recipient_uuid],
|
||||
"number": settings.SIGNAL_NUMBER,
|
||||
"base64_attachments": [],
|
||||
}
|
||||
|
||||
attachments = attachments or []
|
||||
|
||||
# Convert attachments to Base64
|
||||
for att in attachments:
|
||||
base64_data = download_and_encode_base64_sync(
|
||||
@@ -225,7 +228,7 @@ def send_message_raw_sync(recipient_uuid, text=None, attachments=[]):
|
||||
try:
|
||||
response = requests.post(url, json=data, timeout=10)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
except requests.RequestException:
|
||||
# log.error(f"Failed to send Signal message: {e}")
|
||||
return False
|
||||
|
||||
|
||||
417
core/clients/transport.py
Normal file
417
core/clients/transport.py
Normal file
@@ -0,0 +1,417 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import orjson
|
||||
import qrcode
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from core.clients import signalapi
|
||||
from core.messaging import media_bridge
|
||||
from core.util import logs
|
||||
|
||||
log = logs.get_logger("transport")
|
||||
|
||||
_RUNTIME_STATE_TTL = 60 * 60 * 24
|
||||
_RUNTIME_CLIENTS: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _service_key(service: str) -> str:
|
||||
return str(service or "").strip().lower()
|
||||
|
||||
|
||||
def _runtime_key(service: str) -> str:
|
||||
return f"gia:service:runtime:{_service_key(service)}"
|
||||
|
||||
|
||||
def _gateway_base(service: str) -> str:
|
||||
key = f"{service.upper()}_HTTP_URL"
|
||||
default = f"http://{service}:8080"
|
||||
return str(getattr(settings, key, default)).rstrip("/")
|
||||
|
||||
|
||||
def _as_qr_png(data: str) -> bytes:
|
||||
image = qrcode.make(data)
|
||||
stream = io.BytesIO()
|
||||
image.save(stream, format="PNG")
|
||||
return stream.getvalue()
|
||||
|
||||
|
||||
def _parse_timestamp(data: Any):
|
||||
if isinstance(data, dict):
|
||||
ts = data.get("timestamp")
|
||||
if ts:
|
||||
return ts
|
||||
return None
|
||||
|
||||
|
||||
def register_runtime_client(service: str, client: Any):
|
||||
"""
|
||||
Register an in-process runtime client (UR process).
|
||||
"""
|
||||
_RUNTIME_CLIENTS[_service_key(service)] = client
|
||||
|
||||
|
||||
def get_runtime_client(service: str):
|
||||
return _RUNTIME_CLIENTS.get(_service_key(service))
|
||||
|
||||
|
||||
def get_runtime_state(service: str) -> dict[str, Any]:
|
||||
return dict(cache.get(_runtime_key(service)) or {})
|
||||
|
||||
|
||||
def update_runtime_state(service: str, **updates):
|
||||
"""
|
||||
Persist runtime state to shared cache so web/UI process can read it.
|
||||
"""
|
||||
key = _runtime_key(service)
|
||||
state = dict(cache.get(key) or {})
|
||||
state.update(updates)
|
||||
state["updated_at"] = int(time.time())
|
||||
cache.set(key, state, timeout=_RUNTIME_STATE_TTL)
|
||||
return state
|
||||
|
||||
|
||||
def list_accounts(service: str):
|
||||
"""
|
||||
Return account identifiers for service UI list.
|
||||
"""
|
||||
service_key = _service_key(service)
|
||||
if service_key == "signal":
|
||||
import requests
|
||||
|
||||
base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip("/")
|
||||
try:
|
||||
response = requests.get(f"{base}/v1/accounts", timeout=20)
|
||||
if not response.ok:
|
||||
return []
|
||||
payload = orjson.loads(response.text or "[]")
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
state = get_runtime_state(service_key)
|
||||
accounts = state.get("accounts") or []
|
||||
if isinstance(accounts, list):
|
||||
return accounts
|
||||
return []
|
||||
|
||||
|
||||
def get_service_warning(service: str) -> str:
|
||||
service_key = _service_key(service)
|
||||
if service_key == "signal":
|
||||
return ""
|
||||
|
||||
state = get_runtime_state(service_key)
|
||||
warning = str(state.get("warning") or "").strip()
|
||||
if warning:
|
||||
return warning
|
||||
|
||||
if not state.get("connected"):
|
||||
return (
|
||||
f"{service_key.title()} runtime is not connected yet. "
|
||||
"Start UR with the service enabled, open Services -> "
|
||||
f"{service_key.title()} -> Add Account, then scan the QR from "
|
||||
"WhatsApp Linked Devices."
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
async def _gateway_json(method: str, url: str, payload=None):
|
||||
timeout = aiohttp.ClientTimeout(total=20)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
request = getattr(session, method.lower())
|
||||
async with request(url, json=payload) as response:
|
||||
body = await response.read()
|
||||
if not body:
|
||||
return response.status, None
|
||||
try:
|
||||
return response.status, orjson.loads(body)
|
||||
except Exception:
|
||||
return response.status, None
|
||||
|
||||
|
||||
async def _normalize_gateway_attachment(service: str, row: dict, session):
|
||||
normalized = dict(row or {})
|
||||
content = normalized.get("content")
|
||||
if isinstance(content, memoryview):
|
||||
content = content.tobytes()
|
||||
if isinstance(content, bytes):
|
||||
blob_key = media_bridge.put_blob(
|
||||
service=service,
|
||||
content=content,
|
||||
filename=normalized.get("filename") or "attachment.bin",
|
||||
content_type=normalized.get("content_type") or "application/octet-stream",
|
||||
)
|
||||
return {
|
||||
"blob_key": blob_key,
|
||||
"filename": normalized.get("filename") or "attachment.bin",
|
||||
"content_type": normalized.get("content_type")
|
||||
or "application/octet-stream",
|
||||
"size": normalized.get("size") or len(content),
|
||||
}
|
||||
|
||||
if normalized.get("blob_key"):
|
||||
return normalized
|
||||
|
||||
source_url = normalized.get("url")
|
||||
if source_url:
|
||||
try:
|
||||
async with session.get(source_url) as response:
|
||||
if response.status == 200:
|
||||
payload = await response.read()
|
||||
blob_key = media_bridge.put_blob(
|
||||
service=service,
|
||||
content=payload,
|
||||
filename=normalized.get("filename")
|
||||
or source_url.rstrip("/").split("/")[-1]
|
||||
or "attachment.bin",
|
||||
content_type=normalized.get("content_type")
|
||||
or response.headers.get(
|
||||
"Content-Type", "application/octet-stream"
|
||||
),
|
||||
)
|
||||
return {
|
||||
"blob_key": blob_key,
|
||||
"filename": normalized.get("filename")
|
||||
or source_url.rstrip("/").split("/")[-1]
|
||||
or "attachment.bin",
|
||||
"content_type": normalized.get("content_type")
|
||||
or response.headers.get(
|
||||
"Content-Type", "application/octet-stream"
|
||||
),
|
||||
"size": normalized.get("size") or len(payload),
|
||||
}
|
||||
except Exception:
|
||||
log.warning("%s attachment fetch failed for %s", service, source_url)
|
||||
return normalized
|
||||
|
||||
|
||||
async def _gateway_send(service: str, recipient: str, text=None, attachments=None):
|
||||
base = _gateway_base(service)
|
||||
url = f"{base}/v1/send"
|
||||
timeout = aiohttp.ClientTimeout(total=20)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as media_session:
|
||||
normalized_attachments = await asyncio.gather(
|
||||
*[
|
||||
_normalize_gateway_attachment(service, dict(att or {}), media_session)
|
||||
for att in (attachments or [])
|
||||
]
|
||||
)
|
||||
|
||||
data = {
|
||||
"recipient": recipient,
|
||||
"text": text or "",
|
||||
"attachments": normalized_attachments,
|
||||
}
|
||||
status, payload = await _gateway_json("post", url, data)
|
||||
if 200 <= status < 300:
|
||||
ts = _parse_timestamp(payload)
|
||||
return ts if ts else True
|
||||
log.warning("%s gateway send failed (%s): %s", service, status, payload)
|
||||
return False
|
||||
|
||||
|
||||
async def _gateway_typing(service: str, recipient: str, started: bool):
|
||||
base = _gateway_base(service)
|
||||
action = "start" if started else "stop"
|
||||
url = f"{base}/v1/typing/{action}"
|
||||
payload = {"recipient": recipient}
|
||||
status, _ = await _gateway_json("post", url, payload)
|
||||
if 200 <= status < 300:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def send_message_raw(service: str, recipient: str, text=None, attachments=None):
|
||||
"""
|
||||
Unified outbound send path used by models/views/UR.
|
||||
"""
|
||||
service_key = _service_key(service)
|
||||
if service_key == "signal":
|
||||
return await signalapi.send_message_raw(recipient, text, attachments or [])
|
||||
|
||||
if service_key in {"whatsapp", "instagram"}:
|
||||
runtime_client = get_runtime_client(service_key)
|
||||
if runtime_client and hasattr(runtime_client, "send_message_raw"):
|
||||
try:
|
||||
runtime_result = await runtime_client.send_message_raw(
|
||||
recipient,
|
||||
text=text,
|
||||
attachments=attachments or [],
|
||||
)
|
||||
if runtime_result is not False and runtime_result is not None:
|
||||
return runtime_result
|
||||
except Exception as exc:
|
||||
log.warning("%s runtime send failed: %s", service_key, exc)
|
||||
return await _gateway_send(
|
||||
service_key,
|
||||
recipient,
|
||||
text=text,
|
||||
attachments=attachments or [],
|
||||
)
|
||||
|
||||
if service_key == "xmpp":
|
||||
raise NotImplementedError("Direct XMPP send is handled by the XMPP client.")
|
||||
raise NotImplementedError(f"Unsupported service: {service}")
|
||||
|
||||
|
||||
async def start_typing(service: str, recipient: str):
|
||||
service_key = _service_key(service)
|
||||
if service_key == "signal":
|
||||
await signalapi.start_typing(recipient)
|
||||
return True
|
||||
|
||||
if service_key in {"whatsapp", "instagram"}:
|
||||
runtime_client = get_runtime_client(service_key)
|
||||
if runtime_client and hasattr(runtime_client, "start_typing"):
|
||||
try:
|
||||
result = await runtime_client.start_typing(recipient)
|
||||
if result:
|
||||
return True
|
||||
except Exception as exc:
|
||||
log.warning("%s runtime start_typing failed: %s", service_key, exc)
|
||||
return await _gateway_typing(service_key, recipient, started=True)
|
||||
return False
|
||||
|
||||
|
||||
async def stop_typing(service: str, recipient: str):
|
||||
service_key = _service_key(service)
|
||||
if service_key == "signal":
|
||||
await signalapi.stop_typing(recipient)
|
||||
return True
|
||||
|
||||
if service_key in {"whatsapp", "instagram"}:
|
||||
runtime_client = get_runtime_client(service_key)
|
||||
if runtime_client and hasattr(runtime_client, "stop_typing"):
|
||||
try:
|
||||
result = await runtime_client.stop_typing(recipient)
|
||||
if result:
|
||||
return True
|
||||
except Exception as exc:
|
||||
log.warning("%s runtime stop_typing failed: %s", service_key, exc)
|
||||
return await _gateway_typing(service_key, recipient, started=False)
|
||||
return False
|
||||
|
||||
|
||||
async def fetch_attachment(service: str, attachment_ref: dict):
|
||||
"""
|
||||
Fetch attachment bytes from a source service or URL.
|
||||
"""
|
||||
service_key = _service_key(service)
|
||||
if service_key == "signal":
|
||||
attachment_id = attachment_ref.get("id") or attachment_ref.get("attachment_id")
|
||||
if not attachment_id:
|
||||
return None
|
||||
return await signalapi.fetch_signal_attachment(attachment_id)
|
||||
|
||||
runtime_client = get_runtime_client(service_key)
|
||||
if runtime_client and hasattr(runtime_client, "fetch_attachment"):
|
||||
try:
|
||||
from_runtime = await runtime_client.fetch_attachment(attachment_ref)
|
||||
if from_runtime:
|
||||
return from_runtime
|
||||
except Exception as exc:
|
||||
log.warning("%s runtime attachment fetch failed: %s", service_key, exc)
|
||||
|
||||
direct_url = attachment_ref.get("url")
|
||||
blob_key = attachment_ref.get("blob_key")
|
||||
if blob_key:
|
||||
return media_bridge.get_blob(blob_key)
|
||||
if direct_url:
|
||||
timeout = aiohttp.ClientTimeout(total=20)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(direct_url) as response:
|
||||
if response.status != 200:
|
||||
return None
|
||||
content = await response.read()
|
||||
return {
|
||||
"content": content,
|
||||
"content_type": response.headers.get(
|
||||
"Content-Type",
|
||||
attachment_ref.get("content_type", "application/octet-stream"),
|
||||
),
|
||||
"filename": attachment_ref.get("filename")
|
||||
or direct_url.rstrip("/").split("/")[-1]
|
||||
or "attachment.bin",
|
||||
"size": len(content),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _qr_from_runtime_state(service: str) -> bytes | None:
|
||||
state = get_runtime_state(service)
|
||||
qr_payload = str(state.get("pair_qr") or "").strip()
|
||||
if not qr_payload:
|
||||
return None
|
||||
if qr_payload.startswith("data:image/") and "," in qr_payload:
|
||||
_, b64_data = qr_payload.split(",", 1)
|
||||
try:
|
||||
return base64.b64decode(b64_data)
|
||||
except Exception:
|
||||
return None
|
||||
return _as_qr_png(qr_payload)
|
||||
|
||||
|
||||
def get_link_qr(service: str, device_name: str):
|
||||
"""
|
||||
Returns PNG bytes for account-linking QR.
|
||||
|
||||
- Signal: uses signal-cli REST endpoint.
|
||||
- WhatsApp/Instagram: runtime QR from shared state when available.
|
||||
Falls back to local pairing token QR in development.
|
||||
"""
|
||||
service_key = _service_key(service)
|
||||
device = (device_name or "GIA Device").strip()
|
||||
|
||||
if service_key == "signal":
|
||||
import requests
|
||||
|
||||
base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip("/")
|
||||
response = requests.get(
|
||||
f"{base}/v1/qrcodelink",
|
||||
params={"device_name": device},
|
||||
timeout=20,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
if service_key in {"whatsapp", "instagram"}:
|
||||
runtime_client = get_runtime_client(service_key)
|
||||
if runtime_client and hasattr(runtime_client, "get_link_qr_png"):
|
||||
try:
|
||||
image_bytes = runtime_client.get_link_qr_png(device)
|
||||
if image_bytes:
|
||||
return image_bytes
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cached = _qr_from_runtime_state(service_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
token = secrets.token_urlsafe(24)
|
||||
uri = f"gia://{service_key}/link?device={device}&token={token}"
|
||||
update_runtime_state(
|
||||
service_key,
|
||||
pair_device=device,
|
||||
pair_requested_at=int(time.time()),
|
||||
warning=(
|
||||
"Waiting for runtime pairing QR. "
|
||||
"If this persists, check UR logs and Neonize session state."
|
||||
),
|
||||
)
|
||||
return _as_qr_png(uri)
|
||||
|
||||
raise NotImplementedError(f"Unsupported service for QR linking: {service}")
|
||||
|
||||
|
||||
def image_bytes_to_base64(image_bytes: bytes) -> str:
|
||||
return base64.b64encode(image_bytes).decode("utf-8")
|
||||
627
core/clients/whatsapp.py
Normal file
627
core/clients/whatsapp.py
Normal file
@@ -0,0 +1,627 @@
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
|
||||
import aiohttp
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
|
||||
from core.clients import ClientBase, transport
|
||||
from core.messaging import history, media_bridge
|
||||
from core.models import PersonIdentifier
|
||||
|
||||
|
||||
class WhatsAppClient(ClientBase):
|
||||
"""
|
||||
Async WhatsApp transport backed by Neonize.
|
||||
|
||||
Design notes:
|
||||
- Runs in UR process.
|
||||
- Publishes runtime state to shared cache via transport.
|
||||
- Degrades gracefully when Neonize/session is unavailable.
|
||||
"""
|
||||
|
||||
def __init__(self, ur, loop, service="whatsapp"):
|
||||
super().__init__(ur, loop, service)
|
||||
self._task = None
|
||||
self._stopping = False
|
||||
self._client = None
|
||||
self._build_jid = None
|
||||
self._connected = False
|
||||
self._last_qr_payload = ""
|
||||
self._accounts = []
|
||||
|
||||
self.enabled = bool(
|
||||
str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower()
|
||||
in {"1", "true", "yes", "on"}
|
||||
)
|
||||
self.client_name = str(
|
||||
getattr(settings, "WHATSAPP_CLIENT_NAME", "gia_whatsapp")
|
||||
).strip() or "gia_whatsapp"
|
||||
self.database_url = str(
|
||||
getattr(settings, "WHATSAPP_DATABASE_URL", "")
|
||||
).strip()
|
||||
|
||||
transport.register_runtime_client(self.service, self)
|
||||
self._publish_state(
|
||||
connected=False,
|
||||
warning=(
|
||||
"WhatsApp runtime is disabled by settings."
|
||||
if not self.enabled
|
||||
else ""
|
||||
),
|
||||
accounts=[],
|
||||
)
|
||||
|
||||
def _publish_state(self, **updates):
|
||||
state = transport.update_runtime_state(self.service, **updates)
|
||||
accounts = state.get("accounts")
|
||||
if isinstance(accounts, list):
|
||||
self._accounts = accounts
|
||||
|
||||
def start(self):
|
||||
if not self.enabled:
|
||||
self.log.info("whatsapp client disabled by settings")
|
||||
return
|
||||
if self._task is None:
|
||||
self.log.info("whatsapp neonize client starting")
|
||||
self._task = self.loop.create_task(self._run())
|
||||
|
||||
async def _run(self):
|
||||
try:
|
||||
from neonize.aioze.client import NewAClient
|
||||
from neonize.aioze import events as wa_events
|
||||
try:
|
||||
from neonize.utils import build_jid as wa_build_jid
|
||||
except Exception:
|
||||
wa_build_jid = None
|
||||
except Exception as exc:
|
||||
self._publish_state(
|
||||
connected=False,
|
||||
warning=f"Neonize not available: {exc}",
|
||||
accounts=[],
|
||||
)
|
||||
self.log.warning("whatsapp neonize import failed: %s", exc)
|
||||
return
|
||||
|
||||
self._build_jid = wa_build_jid
|
||||
self._client = self._build_client(NewAClient)
|
||||
if self._client is None:
|
||||
self._publish_state(
|
||||
connected=False,
|
||||
warning="Failed to initialize Neonize client.",
|
||||
accounts=[],
|
||||
)
|
||||
return
|
||||
|
||||
self._register_event_handlers(wa_events)
|
||||
|
||||
try:
|
||||
await self._maybe_await(self._client.connect())
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._publish_state(
|
||||
connected=False,
|
||||
warning=f"WhatsApp connect failed: {exc}",
|
||||
accounts=[],
|
||||
)
|
||||
self.log.warning("whatsapp connect failed: %s", exc)
|
||||
return
|
||||
|
||||
# Keep task alive so state/callbacks remain active.
|
||||
while not self._stopping:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
def _build_client(self, cls):
|
||||
candidates = []
|
||||
if self.database_url:
|
||||
candidates.append((self.client_name, self.database_url))
|
||||
candidates.append((self.client_name,))
|
||||
for args in candidates:
|
||||
try:
|
||||
return cls(*args)
|
||||
except TypeError:
|
||||
continue
|
||||
except Exception as exc:
|
||||
self.log.warning("whatsapp client init failed for args %s: %s", args, exc)
|
||||
try:
|
||||
if self.database_url:
|
||||
return cls(name=self.client_name, database=self.database_url)
|
||||
return cls(name=self.client_name)
|
||||
except Exception as exc:
|
||||
self.log.warning("whatsapp client init failed: %s", exc)
|
||||
return None
|
||||
|
||||
def _register_event_handlers(self, wa_events):
|
||||
connected_ev = getattr(wa_events, "ConnectedEv", None)
|
||||
message_ev = getattr(wa_events, "MessageEv", None)
|
||||
receipt_ev = getattr(wa_events, "ReceiptEv", None)
|
||||
presence_ev = getattr(wa_events, "PresenceEv", None)
|
||||
pair_ev = getattr(wa_events, "PairStatusEv", None)
|
||||
|
||||
if connected_ev is not None:
|
||||
|
||||
async def on_connected(client, event: connected_ev):
|
||||
self._connected = True
|
||||
account = await self._resolve_account_identifier()
|
||||
self._publish_state(
|
||||
connected=True,
|
||||
warning="",
|
||||
accounts=[account] if account else [self.client_name],
|
||||
)
|
||||
|
||||
self._client.event(on_connected)
|
||||
|
||||
if message_ev is not None:
|
||||
|
||||
async def on_message(client, event: message_ev):
|
||||
await self._handle_message_event(event)
|
||||
|
||||
self._client.event(on_message)
|
||||
|
||||
if receipt_ev is not None:
|
||||
|
||||
async def on_receipt(client, event: receipt_ev):
|
||||
await self._handle_receipt_event(event)
|
||||
|
||||
self._client.event(on_receipt)
|
||||
|
||||
if presence_ev is not None:
|
||||
|
||||
async def on_presence(client, event: presence_ev):
|
||||
await self._handle_presence_event(event)
|
||||
|
||||
self._client.event(on_presence)
|
||||
|
||||
if pair_ev is not None:
|
||||
|
||||
async def on_pair_status(client, event: pair_ev):
|
||||
qr_payload = self._extract_pair_qr(event)
|
||||
if qr_payload:
|
||||
self._last_qr_payload = qr_payload
|
||||
self._publish_state(
|
||||
pair_qr=qr_payload,
|
||||
warning="Scan QR to pair WhatsApp account.",
|
||||
)
|
||||
|
||||
self._client.event(on_pair_status)
|
||||
|
||||
async def _maybe_await(self, value):
|
||||
if asyncio.iscoroutine(value):
|
||||
return await value
|
||||
return value
|
||||
|
||||
async def _resolve_account_identifier(self):
|
||||
if self._client is None:
|
||||
return ""
|
||||
if not hasattr(self._client, "get_me"):
|
||||
return self.client_name
|
||||
try:
|
||||
me = await self._maybe_await(self._client.get_me())
|
||||
except Exception:
|
||||
return self.client_name
|
||||
# Support both dict-like and object-like payloads.
|
||||
for path in (
|
||||
("JID", "User"),
|
||||
("jid",),
|
||||
("user",),
|
||||
("ID",),
|
||||
):
|
||||
value = self._pluck(me, *path)
|
||||
if value:
|
||||
return str(value)
|
||||
return self.client_name
|
||||
|
||||
def _pluck(self, obj, *path):
|
||||
current = obj
|
||||
for key in path:
|
||||
if current is None:
|
||||
return None
|
||||
if isinstance(current, dict):
|
||||
current = current.get(key)
|
||||
continue
|
||||
if hasattr(current, key):
|
||||
current = getattr(current, key)
|
||||
continue
|
||||
return None
|
||||
return current
|
||||
|
||||
def _normalize_timestamp(self, raw_value):
|
||||
if raw_value is None:
|
||||
return int(time.time() * 1000)
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except Exception:
|
||||
return int(time.time() * 1000)
|
||||
# WhatsApp libs often emit seconds. Promote to ms.
|
||||
if value < 10**12:
|
||||
return value * 1000
|
||||
return value
|
||||
|
||||
def _normalize_identifier_candidates(self, *values):
|
||||
out = set()
|
||||
for value in values:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
out.add(raw)
|
||||
if "@" in raw:
|
||||
out.add(raw.split("@", 1)[0])
|
||||
digits = re.sub(r"[^0-9]", "", raw)
|
||||
if digits:
|
||||
out.add(digits)
|
||||
if not digits.startswith("+"):
|
||||
out.add(f"+{digits}")
|
||||
return out
|
||||
|
||||
def _is_media_message(self, message_obj):
|
||||
media_fields = (
|
||||
"imageMessage",
|
||||
"videoMessage",
|
||||
"audioMessage",
|
||||
"documentMessage",
|
||||
"stickerMessage",
|
||||
"image_message",
|
||||
"video_message",
|
||||
"audio_message",
|
||||
"document_message",
|
||||
"sticker_message",
|
||||
)
|
||||
for field in media_fields:
|
||||
value = self._pluck(message_obj, field)
|
||||
if value:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _download_event_media(self, event):
|
||||
if not self._client:
|
||||
return []
|
||||
msg_obj = self._pluck(event, "message")
|
||||
if msg_obj is None or not self._is_media_message(msg_obj):
|
||||
return []
|
||||
if not hasattr(self._client, "download_any"):
|
||||
return []
|
||||
|
||||
try:
|
||||
payload = await self._maybe_await(self._client.download_any(msg_obj))
|
||||
except Exception as exc:
|
||||
self.log.warning("whatsapp media download failed: %s", exc)
|
||||
return []
|
||||
|
||||
if isinstance(payload, memoryview):
|
||||
payload = payload.tobytes()
|
||||
if not isinstance(payload, (bytes, bytearray)):
|
||||
return []
|
||||
|
||||
filename = (
|
||||
self._pluck(msg_obj, "documentMessage", "fileName")
|
||||
or self._pluck(msg_obj, "document_message", "file_name")
|
||||
or f"wa-{int(time.time())}.bin"
|
||||
)
|
||||
content_type = (
|
||||
self._pluck(msg_obj, "documentMessage", "mimetype")
|
||||
or self._pluck(msg_obj, "document_message", "mimetype")
|
||||
or self._pluck(msg_obj, "imageMessage", "mimetype")
|
||||
or self._pluck(msg_obj, "image_message", "mimetype")
|
||||
or "application/octet-stream"
|
||||
)
|
||||
blob_key = media_bridge.put_blob(
|
||||
service="whatsapp",
|
||||
content=bytes(payload),
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
)
|
||||
if not blob_key:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"blob_key": blob_key,
|
||||
"filename": filename,
|
||||
"content_type": content_type,
|
||||
"size": len(payload),
|
||||
}
|
||||
]
|
||||
|
||||
async def _handle_message_event(self, event):
|
||||
msg_obj = self._pluck(event, "message")
|
||||
text = (
|
||||
self._pluck(msg_obj, "conversation")
|
||||
or self._pluck(msg_obj, "extendedTextMessage", "text")
|
||||
or self._pluck(msg_obj, "extended_text_message", "text")
|
||||
or ""
|
||||
)
|
||||
|
||||
sender = (
|
||||
self._pluck(event, "info", "message_source", "sender")
|
||||
or self._pluck(event, "info", "messageSource", "sender")
|
||||
or ""
|
||||
)
|
||||
chat = (
|
||||
self._pluck(event, "info", "message_source", "chat")
|
||||
or self._pluck(event, "info", "messageSource", "chat")
|
||||
or ""
|
||||
)
|
||||
raw_ts = (
|
||||
self._pluck(event, "info", "timestamp")
|
||||
or self._pluck(event, "info", "message_timestamp")
|
||||
or self._pluck(event, "timestamp")
|
||||
)
|
||||
ts = self._normalize_timestamp(raw_ts)
|
||||
|
||||
identifier_values = self._normalize_identifier_candidates(sender, chat)
|
||||
if not identifier_values:
|
||||
return
|
||||
|
||||
identifiers = await sync_to_async(list)(
|
||||
PersonIdentifier.objects.filter(
|
||||
service="whatsapp",
|
||||
identifier__in=list(identifier_values),
|
||||
)
|
||||
)
|
||||
if not identifiers:
|
||||
return
|
||||
|
||||
attachments = await self._download_event_media(event)
|
||||
xmpp_attachments = []
|
||||
if attachments:
|
||||
fetched = await asyncio.gather(
|
||||
*[transport.fetch_attachment(self.service, att) for att in attachments]
|
||||
)
|
||||
xmpp_attachments = [row for row in fetched if row]
|
||||
|
||||
payload = {
|
||||
"sender": str(sender or ""),
|
||||
"chat": str(chat or ""),
|
||||
"raw_event": str(type(event).__name__),
|
||||
}
|
||||
|
||||
for identifier in identifiers:
|
||||
session = await history.get_chat_session(identifier.user, identifier)
|
||||
await history.store_message(
|
||||
session=session,
|
||||
sender=str(sender or chat or ""),
|
||||
text=text,
|
||||
ts=ts,
|
||||
outgoing=False,
|
||||
)
|
||||
await self.ur.xmpp.client.send_from_external(
|
||||
identifier.user,
|
||||
identifier,
|
||||
text,
|
||||
is_outgoing_message=False,
|
||||
attachments=xmpp_attachments,
|
||||
)
|
||||
await self.ur.message_received(
|
||||
self.service,
|
||||
identifier=identifier,
|
||||
text=text,
|
||||
ts=ts,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
async def _handle_receipt_event(self, event):
|
||||
sender = (
|
||||
self._pluck(event, "info", "message_source", "sender")
|
||||
or self._pluck(event, "info", "messageSource", "sender")
|
||||
or ""
|
||||
)
|
||||
chat = (
|
||||
self._pluck(event, "info", "message_source", "chat")
|
||||
or self._pluck(event, "info", "messageSource", "chat")
|
||||
or ""
|
||||
)
|
||||
timestamps = []
|
||||
raw_ids = self._pluck(event, "message_ids") or []
|
||||
if isinstance(raw_ids, list):
|
||||
for item in raw_ids:
|
||||
try:
|
||||
value = int(item)
|
||||
timestamps.append(value * 1000 if value < 10**12 else value)
|
||||
except Exception:
|
||||
continue
|
||||
read_ts = self._normalize_timestamp(self._pluck(event, "timestamp") or int(time.time() * 1000))
|
||||
|
||||
for candidate in self._normalize_identifier_candidates(sender, chat):
|
||||
await self.ur.message_read(
|
||||
self.service,
|
||||
identifier=candidate,
|
||||
message_timestamps=timestamps,
|
||||
read_ts=read_ts,
|
||||
read_by=sender or chat,
|
||||
payload={"event": "receipt", "sender": str(sender), "chat": str(chat)},
|
||||
)
|
||||
|
||||
async def _handle_presence_event(self, event):
|
||||
sender = (
|
||||
self._pluck(event, "message_source", "sender")
|
||||
or self._pluck(event, "info", "message_source", "sender")
|
||||
or ""
|
||||
)
|
||||
chat = (
|
||||
self._pluck(event, "message_source", "chat")
|
||||
or self._pluck(event, "info", "message_source", "chat")
|
||||
or ""
|
||||
)
|
||||
presence = str(self._pluck(event, "presence") or "").strip().lower()
|
||||
|
||||
for candidate in self._normalize_identifier_candidates(sender, chat):
|
||||
if presence in {"composing", "typing", "recording"}:
|
||||
await self.ur.started_typing(
|
||||
self.service,
|
||||
identifier=candidate,
|
||||
payload={"presence": presence, "sender": str(sender), "chat": str(chat)},
|
||||
)
|
||||
elif presence:
|
||||
await self.ur.stopped_typing(
|
||||
self.service,
|
||||
identifier=candidate,
|
||||
payload={"presence": presence, "sender": str(sender), "chat": str(chat)},
|
||||
)
|
||||
|
||||
def _extract_pair_qr(self, event):
|
||||
for path in (
|
||||
("qr",),
|
||||
("qr_code",),
|
||||
("code",),
|
||||
("pair_code",),
|
||||
("pairCode",),
|
||||
("url",),
|
||||
):
|
||||
value = self._pluck(event, *path)
|
||||
if value:
|
||||
return str(value)
|
||||
return ""
|
||||
|
||||
def _to_jid(self, recipient):
|
||||
raw = str(recipient or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if self._build_jid is not None:
|
||||
try:
|
||||
return self._build_jid(raw)
|
||||
except Exception:
|
||||
pass
|
||||
if "@" in raw:
|
||||
return raw
|
||||
digits = re.sub(r"[^0-9]", "", raw)
|
||||
if digits:
|
||||
return f"{digits}@s.whatsapp.net"
|
||||
return raw
|
||||
|
||||
async def _fetch_attachment_payload(self, attachment):
|
||||
blob_key = (attachment or {}).get("blob_key")
|
||||
if blob_key:
|
||||
row = media_bridge.get_blob(blob_key)
|
||||
if row:
|
||||
return row
|
||||
|
||||
content = (attachment or {}).get("content")
|
||||
if isinstance(content, memoryview):
|
||||
content = content.tobytes()
|
||||
if isinstance(content, bytes):
|
||||
return {
|
||||
"content": content,
|
||||
"filename": (attachment or {}).get("filename") or "attachment.bin",
|
||||
"content_type": (attachment or {}).get("content_type")
|
||||
or "application/octet-stream",
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
url = (attachment or {}).get("url")
|
||||
if url:
|
||||
timeout = aiohttp.ClientTimeout(total=20)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url) as response:
|
||||
if response.status != 200:
|
||||
return None
|
||||
payload = await response.read()
|
||||
return {
|
||||
"content": payload,
|
||||
"filename": (attachment or {}).get("filename")
|
||||
or url.rstrip("/").split("/")[-1]
|
||||
or "attachment.bin",
|
||||
"content_type": (attachment or {}).get("content_type")
|
||||
or response.headers.get(
|
||||
"Content-Type", "application/octet-stream"
|
||||
),
|
||||
"size": len(payload),
|
||||
}
|
||||
return None
|
||||
|
||||
async def send_message_raw(self, recipient, text=None, attachments=None):
|
||||
if not self._client:
|
||||
return False
|
||||
jid = self._to_jid(recipient)
|
||||
if not jid:
|
||||
return False
|
||||
|
||||
sent_any = False
|
||||
for attachment in attachments or []:
|
||||
payload = await self._fetch_attachment_payload(attachment)
|
||||
if not payload:
|
||||
continue
|
||||
mime = str(payload.get("content_type") or "application/octet-stream").lower()
|
||||
data = payload.get("content") or b""
|
||||
filename = payload.get("filename") or "attachment.bin"
|
||||
|
||||
try:
|
||||
if mime.startswith("image/") and hasattr(self._client, "send_image"):
|
||||
await self._maybe_await(self._client.send_image(jid, data, caption=""))
|
||||
elif mime.startswith("video/") and hasattr(self._client, "send_video"):
|
||||
await self._maybe_await(self._client.send_video(jid, data, caption=""))
|
||||
elif mime.startswith("audio/") and hasattr(self._client, "send_audio"):
|
||||
await self._maybe_await(self._client.send_audio(jid, data))
|
||||
elif hasattr(self._client, "send_document"):
|
||||
await self._maybe_await(
|
||||
self._client.send_document(
|
||||
jid,
|
||||
data,
|
||||
filename=filename,
|
||||
mimetype=mime,
|
||||
caption="",
|
||||
)
|
||||
)
|
||||
sent_any = True
|
||||
except Exception as exc:
|
||||
self.log.warning("whatsapp attachment send failed: %s", exc)
|
||||
|
||||
if text:
|
||||
try:
|
||||
await self._maybe_await(self._client.send_message(jid, text))
|
||||
sent_any = True
|
||||
except TypeError:
|
||||
await self._maybe_await(self._client.send_message(jid, message=text))
|
||||
sent_any = True
|
||||
except Exception as exc:
|
||||
self.log.warning("whatsapp text send failed: %s", exc)
|
||||
return False
|
||||
|
||||
return int(time.time() * 1000) if sent_any else False
|
||||
|
||||
async def start_typing(self, identifier):
|
||||
if not self._client:
|
||||
return False
|
||||
jid = self._to_jid(identifier)
|
||||
if not jid:
|
||||
return False
|
||||
for method_name in ("send_chat_presence", "set_chat_presence"):
|
||||
if hasattr(self._client, method_name):
|
||||
method = getattr(self._client, method_name)
|
||||
try:
|
||||
await self._maybe_await(method(jid, "composing"))
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
async def stop_typing(self, identifier):
|
||||
if not self._client:
|
||||
return False
|
||||
jid = self._to_jid(identifier)
|
||||
if not jid:
|
||||
return False
|
||||
for method_name in ("send_chat_presence", "set_chat_presence"):
|
||||
if hasattr(self._client, method_name):
|
||||
method = getattr(self._client, method_name)
|
||||
try:
|
||||
await self._maybe_await(method(jid, "paused"))
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
async def fetch_attachment(self, attachment_ref):
|
||||
blob_key = (attachment_ref or {}).get("blob_key")
|
||||
if blob_key:
|
||||
return media_bridge.get_blob(blob_key)
|
||||
return None
|
||||
|
||||
def get_link_qr_png(self, device_name):
|
||||
_ = (device_name or "").strip()
|
||||
if not self._last_qr_payload:
|
||||
return None
|
||||
try:
|
||||
return transport._as_qr_png(self._last_qr_payload)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -10,12 +10,13 @@ from slixmpp.stanza import Message
|
||||
from slixmpp.xmlstream import register_stanza_plugin
|
||||
from slixmpp.xmlstream.stanzabase import ET
|
||||
|
||||
from core.clients import ClientBase, signalapi
|
||||
from core.clients import ClientBase
|
||||
from core.messaging import ai, history, replies, utils
|
||||
from core.models import (
|
||||
ChatSession,
|
||||
Manipulation,
|
||||
PatternMitigationAutoSettings,
|
||||
PatternMitigationCorrection,
|
||||
PatternMitigationGame,
|
||||
PatternMitigationPlan,
|
||||
PatternMitigationRule,
|
||||
@@ -91,21 +92,13 @@ class XMPPComponent(ComponentXMPP):
|
||||
sender_bare_jid = sender_parts[0] # Always present: user@domain
|
||||
sender_username, sender_domain = sender_bare_jid.split("@", 1)
|
||||
|
||||
sender_resource = (
|
||||
sender_parts[1] if len(sender_parts) > 1 else None
|
||||
) # Extract resource if present
|
||||
|
||||
# Extract recipient JID (should match component JID format)
|
||||
recipient_jid = str(msg["to"])
|
||||
|
||||
if "@" in recipient_jid:
|
||||
recipient_username, recipient_domain = recipient_jid.split("@", 1)
|
||||
recipient_username = recipient_jid.split("@", 1)[0]
|
||||
else:
|
||||
recipient_username = recipient_jid
|
||||
recipient_domain = recipient_jid
|
||||
|
||||
# Extract message body
|
||||
body = msg["body"] if msg["body"] else "[No Body]"
|
||||
# Parse recipient_name and recipient_service (e.g., "mark|signal")
|
||||
if "|" in recipient_username:
|
||||
person_name, service = recipient_username.split("|")
|
||||
@@ -134,9 +127,15 @@ class XMPPComponent(ComponentXMPP):
|
||||
return None
|
||||
|
||||
def _get_workspace_conversation(self, user, person):
|
||||
primary_identifier = (
|
||||
PersonIdentifier.objects.filter(user=user, person=person)
|
||||
.order_by("service")
|
||||
.first()
|
||||
)
|
||||
platform_type = primary_identifier.service if primary_identifier else "signal"
|
||||
conversation, _ = WorkspaceConversation.objects.get_or_create(
|
||||
user=user,
|
||||
platform_type="signal",
|
||||
platform_type=platform_type,
|
||||
title=f"{person.name} Workspace",
|
||||
defaults={"platform_thread_id": str(person.id)},
|
||||
)
|
||||
@@ -186,6 +185,10 @@ class XMPPComponent(ComponentXMPP):
|
||||
".mitigation rule-del <person>|<title> | "
|
||||
".mitigation game-add <person>|<title>|<instructions> | "
|
||||
".mitigation game-del <person>|<title> | "
|
||||
".mitigation correction-add <person>|<title>|<clarification> | "
|
||||
".mitigation correction-del <person>|<title> | "
|
||||
".mitigation fundamentals-set <person>|<item1;item2;...> | "
|
||||
".mitigation plan-set <person>|<draft|active|archived>|<auto|guided> | "
|
||||
".mitigation auto <person>|on|off | "
|
||||
".mitigation auto-status <person>"
|
||||
)
|
||||
@@ -214,7 +217,9 @@ class XMPPComponent(ComponentXMPP):
|
||||
if command.startswith(".mitigation show "):
|
||||
person_name = command.replace(".mitigation show ", "", 1).strip().title()
|
||||
person = await sync_to_async(
|
||||
lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first()
|
||||
lambda: Person.objects.filter(
|
||||
user=sender_user, name__iexact=person_name
|
||||
).first()
|
||||
)()
|
||||
if not person:
|
||||
sym("Unknown person.")
|
||||
@@ -231,9 +236,15 @@ class XMPPComponent(ComponentXMPP):
|
||||
if len(parts) < 3:
|
||||
sym("Usage: .mitigation rule-add <person>|<title>|<content>")
|
||||
return True
|
||||
person_name, title, content = parts[0].title(), parts[1], "|".join(parts[2:])
|
||||
person_name, title, content = (
|
||||
parts[0].title(),
|
||||
parts[1],
|
||||
"|".join(parts[2:]),
|
||||
)
|
||||
person = await sync_to_async(
|
||||
lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first()
|
||||
lambda: Person.objects.filter(
|
||||
user=sender_user, name__iexact=person_name
|
||||
).first()
|
||||
)()
|
||||
if not person:
|
||||
sym("Unknown person.")
|
||||
@@ -257,7 +268,9 @@ class XMPPComponent(ComponentXMPP):
|
||||
return True
|
||||
person_name, title = parts[0].title(), "|".join(parts[1:])
|
||||
person = await sync_to_async(
|
||||
lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first()
|
||||
lambda: Person.objects.filter(
|
||||
user=sender_user, name__iexact=person_name
|
||||
).first()
|
||||
)()
|
||||
if not person:
|
||||
sym("Unknown person.")
|
||||
@@ -279,9 +292,15 @@ class XMPPComponent(ComponentXMPP):
|
||||
if len(parts) < 3:
|
||||
sym("Usage: .mitigation game-add <person>|<title>|<instructions>")
|
||||
return True
|
||||
person_name, title, content = parts[0].title(), parts[1], "|".join(parts[2:])
|
||||
person_name, title, content = (
|
||||
parts[0].title(),
|
||||
parts[1],
|
||||
"|".join(parts[2:]),
|
||||
)
|
||||
person = await sync_to_async(
|
||||
lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first()
|
||||
lambda: Person.objects.filter(
|
||||
user=sender_user, name__iexact=person_name
|
||||
).first()
|
||||
)()
|
||||
if not person:
|
||||
sym("Unknown person.")
|
||||
@@ -305,7 +324,9 @@ class XMPPComponent(ComponentXMPP):
|
||||
return True
|
||||
person_name, title = parts[0].title(), "|".join(parts[1:])
|
||||
person = await sync_to_async(
|
||||
lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first()
|
||||
lambda: Person.objects.filter(
|
||||
user=sender_user, name__iexact=person_name
|
||||
).first()
|
||||
)()
|
||||
if not person:
|
||||
sym("Unknown person.")
|
||||
@@ -321,6 +342,128 @@ class XMPPComponent(ComponentXMPP):
|
||||
sym("Game deleted." if deleted else "Game not found.")
|
||||
return True
|
||||
|
||||
if command.startswith(".mitigation correction-add "):
|
||||
payload = command.replace(".mitigation correction-add ", "", 1)
|
||||
parts = parse_parts(payload)
|
||||
if len(parts) < 3:
|
||||
sym(
|
||||
"Usage: .mitigation correction-add <person>|<title>|<clarification>"
|
||||
)
|
||||
return True
|
||||
person_name, title, clarification = (
|
||||
parts[0].title(),
|
||||
parts[1],
|
||||
"|".join(parts[2:]),
|
||||
)
|
||||
person = await sync_to_async(
|
||||
lambda: Person.objects.filter(
|
||||
user=sender_user, name__iexact=person_name
|
||||
).first()
|
||||
)()
|
||||
if not person:
|
||||
sym("Unknown person.")
|
||||
return True
|
||||
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
|
||||
await sync_to_async(PatternMitigationCorrection.objects.create)(
|
||||
user=sender_user,
|
||||
plan=plan,
|
||||
title=title[:255],
|
||||
clarification=clarification,
|
||||
source_phrase="",
|
||||
perspective="second_person",
|
||||
share_target="both",
|
||||
language_style="adapted",
|
||||
enabled=True,
|
||||
)
|
||||
sym("Correction added.")
|
||||
return True
|
||||
|
||||
if command.startswith(".mitigation correction-del "):
|
||||
payload = command.replace(".mitigation correction-del ", "", 1)
|
||||
parts = parse_parts(payload)
|
||||
if len(parts) < 2:
|
||||
sym("Usage: .mitigation correction-del <person>|<title>")
|
||||
return True
|
||||
person_name, title = parts[0].title(), "|".join(parts[1:])
|
||||
person = await sync_to_async(
|
||||
lambda: Person.objects.filter(
|
||||
user=sender_user, name__iexact=person_name
|
||||
).first()
|
||||
)()
|
||||
if not person:
|
||||
sym("Unknown person.")
|
||||
return True
|
||||
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
|
||||
deleted, _ = await sync_to_async(
|
||||
lambda: PatternMitigationCorrection.objects.filter(
|
||||
user=sender_user,
|
||||
plan=plan,
|
||||
title__iexact=title,
|
||||
).delete()
|
||||
)()
|
||||
sym("Correction deleted." if deleted else "Correction not found.")
|
||||
return True
|
||||
|
||||
if command.startswith(".mitigation fundamentals-set "):
|
||||
payload = command.replace(".mitigation fundamentals-set ", "", 1)
|
||||
parts = parse_parts(payload)
|
||||
if len(parts) < 2:
|
||||
sym("Usage: .mitigation fundamentals-set <person>|<item1;item2;...>")
|
||||
return True
|
||||
person_name, values = parts[0].title(), "|".join(parts[1:])
|
||||
person = await sync_to_async(
|
||||
lambda: Person.objects.filter(
|
||||
user=sender_user, name__iexact=person_name
|
||||
).first()
|
||||
)()
|
||||
if not person:
|
||||
sym("Unknown person.")
|
||||
return True
|
||||
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
|
||||
items = [item.strip() for item in values.split(";") if item.strip()]
|
||||
plan.fundamental_items = items
|
||||
await sync_to_async(plan.save)(
|
||||
update_fields=["fundamental_items", "updated_at"]
|
||||
)
|
||||
sym(f"Fundamentals updated ({len(items)}).")
|
||||
return True
|
||||
|
||||
if command.startswith(".mitigation plan-set "):
|
||||
payload = command.replace(".mitigation plan-set ", "", 1)
|
||||
parts = parse_parts(payload)
|
||||
if len(parts) < 3:
|
||||
sym(
|
||||
"Usage: .mitigation plan-set <person>|<draft|active|archived>|<auto|guided>"
|
||||
)
|
||||
return True
|
||||
person_name, status_value, mode_value = (
|
||||
parts[0].title(),
|
||||
parts[1].lower(),
|
||||
parts[2].lower(),
|
||||
)
|
||||
person = await sync_to_async(
|
||||
lambda: Person.objects.filter(
|
||||
user=sender_user, name__iexact=person_name
|
||||
).first()
|
||||
)()
|
||||
if not person:
|
||||
sym("Unknown person.")
|
||||
return True
|
||||
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
|
||||
valid_status = {key for key, _ in PatternMitigationPlan.STATUS_CHOICES}
|
||||
valid_modes = {
|
||||
key for key, _ in PatternMitigationPlan.CREATION_MODE_CHOICES
|
||||
}
|
||||
if status_value in valid_status:
|
||||
plan.status = status_value
|
||||
if mode_value in valid_modes:
|
||||
plan.creation_mode = mode_value
|
||||
await sync_to_async(plan.save)(
|
||||
update_fields=["status", "creation_mode", "updated_at"]
|
||||
)
|
||||
sym(f"Plan updated: status={plan.status}, mode={plan.creation_mode}")
|
||||
return True
|
||||
|
||||
if command.startswith(".mitigation auto "):
|
||||
payload = command.replace(".mitigation auto ", "", 1)
|
||||
parts = parse_parts(payload)
|
||||
@@ -329,31 +472,47 @@ class XMPPComponent(ComponentXMPP):
|
||||
return True
|
||||
person_name, state = parts[0].title(), parts[1].lower()
|
||||
person = await sync_to_async(
|
||||
lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first()
|
||||
lambda: Person.objects.filter(
|
||||
user=sender_user, name__iexact=person_name
|
||||
).first()
|
||||
)()
|
||||
if not person:
|
||||
sym("Unknown person.")
|
||||
return True
|
||||
conversation = await sync_to_async(self._get_workspace_conversation)(sender_user, person)
|
||||
auto_obj, _ = await sync_to_async(PatternMitigationAutoSettings.objects.get_or_create)(
|
||||
conversation = await sync_to_async(self._get_workspace_conversation)(
|
||||
sender_user, person
|
||||
)
|
||||
auto_obj, _ = await sync_to_async(
|
||||
PatternMitigationAutoSettings.objects.get_or_create
|
||||
)(
|
||||
user=sender_user,
|
||||
conversation=conversation,
|
||||
)
|
||||
auto_obj.enabled = state in {"on", "true", "1", "yes"}
|
||||
await sync_to_async(auto_obj.save)(update_fields=["enabled", "updated_at"])
|
||||
sym(f"Automation {'enabled' if auto_obj.enabled else 'disabled'} for {person.name}.")
|
||||
sym(
|
||||
f"Automation {'enabled' if auto_obj.enabled else 'disabled'} for {person.name}."
|
||||
)
|
||||
return True
|
||||
|
||||
if command.startswith(".mitigation auto-status "):
|
||||
person_name = command.replace(".mitigation auto-status ", "", 1).strip().title()
|
||||
person_name = (
|
||||
command.replace(".mitigation auto-status ", "", 1).strip().title()
|
||||
)
|
||||
person = await sync_to_async(
|
||||
lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first()
|
||||
lambda: Person.objects.filter(
|
||||
user=sender_user, name__iexact=person_name
|
||||
).first()
|
||||
)()
|
||||
if not person:
|
||||
sym("Unknown person.")
|
||||
return True
|
||||
conversation = await sync_to_async(self._get_workspace_conversation)(sender_user, person)
|
||||
auto_obj, _ = await sync_to_async(PatternMitigationAutoSettings.objects.get_or_create)(
|
||||
conversation = await sync_to_async(self._get_workspace_conversation)(
|
||||
sender_user, person
|
||||
)
|
||||
auto_obj, _ = await sync_to_async(
|
||||
PatternMitigationAutoSettings.objects.get_or_create
|
||||
)(
|
||||
user=sender_user,
|
||||
conversation=conversation,
|
||||
)
|
||||
@@ -383,7 +542,7 @@ class XMPPComponent(ComponentXMPP):
|
||||
"""
|
||||
self.log.info(f"Chat state: Active from {msg['from']}.")
|
||||
|
||||
identifier = self.get_identifier(msg)
|
||||
self.get_identifier(msg)
|
||||
|
||||
def on_chatstate_composing(self, msg):
|
||||
"""
|
||||
@@ -392,6 +551,13 @@ class XMPPComponent(ComponentXMPP):
|
||||
self.log.info(f"Chat state: Composing from {msg['from']}.")
|
||||
|
||||
identifier = self.get_identifier(msg)
|
||||
if identifier:
|
||||
asyncio.create_task(
|
||||
self.ur.started_typing(
|
||||
"xmpp",
|
||||
identifier=identifier,
|
||||
)
|
||||
)
|
||||
|
||||
def on_chatstate_paused(self, msg):
|
||||
"""
|
||||
@@ -400,6 +566,13 @@ class XMPPComponent(ComponentXMPP):
|
||||
self.log.info(f"Chat state: Paused from {msg['from']}.")
|
||||
|
||||
identifier = self.get_identifier(msg)
|
||||
if identifier:
|
||||
asyncio.create_task(
|
||||
self.ur.stopped_typing(
|
||||
"xmpp",
|
||||
identifier=identifier,
|
||||
)
|
||||
)
|
||||
|
||||
def on_chatstate_inactive(self, msg):
|
||||
"""
|
||||
@@ -407,7 +580,7 @@ class XMPPComponent(ComponentXMPP):
|
||||
"""
|
||||
self.log.info(f"Chat state: Inactive from {msg['from']}.")
|
||||
|
||||
identifier = self.get_identifier(msg)
|
||||
self.get_identifier(msg)
|
||||
|
||||
def on_chatstate_gone(self, msg):
|
||||
"""
|
||||
@@ -415,7 +588,7 @@ class XMPPComponent(ComponentXMPP):
|
||||
"""
|
||||
self.log.info(f"Chat state: Gone from {msg['from']}.")
|
||||
|
||||
identifier = self.get_identifier(msg)
|
||||
self.get_identifier(msg)
|
||||
|
||||
def on_presence_available(self, pres):
|
||||
"""
|
||||
@@ -621,7 +794,9 @@ class XMPPComponent(ComponentXMPP):
|
||||
Process incoming XMPP messages.
|
||||
"""
|
||||
|
||||
sym = lambda x: msg.reply(f"[>] {x}").send()
|
||||
def sym(value):
|
||||
msg.reply(f"[>] {value}").send()
|
||||
|
||||
# self.log.info(f"Received message: {msg}")
|
||||
|
||||
# Extract sender JID (full format: user@domain/resource)
|
||||
@@ -710,7 +885,7 @@ class XMPPComponent(ComponentXMPP):
|
||||
|
||||
# Construct contact list response
|
||||
contact_names = [person.name for person in persons]
|
||||
response_text = f"Contacts: " + ", ".join(contact_names)
|
||||
response_text = "Contacts: " + ", ".join(contact_names)
|
||||
sym(response_text)
|
||||
elif body == ".help":
|
||||
sym("Commands: .contacts, .whoami, .mitigation help")
|
||||
@@ -785,12 +960,11 @@ class XMPPComponent(ComponentXMPP):
|
||||
)
|
||||
self.log.info(f"MANIP11 {manipulations}")
|
||||
if not manipulations:
|
||||
tss = await signalapi.send_message_raw(
|
||||
identifier.identifier,
|
||||
await identifier.send(
|
||||
body,
|
||||
attachments,
|
||||
)
|
||||
self.log.info(f"Message sent unaltered")
|
||||
self.log.info("Message sent unaltered")
|
||||
return
|
||||
|
||||
manip = manipulations.first()
|
||||
@@ -810,12 +984,11 @@ class XMPPComponent(ComponentXMPP):
|
||||
text=result,
|
||||
ts=int(now().timestamp() * 1000),
|
||||
)
|
||||
tss = await signalapi.send_message_raw(
|
||||
identifier.identifier,
|
||||
await identifier.send(
|
||||
result,
|
||||
attachments,
|
||||
)
|
||||
self.log.info(f"Message sent with modifications")
|
||||
self.log.info("Message sent with modifications")
|
||||
|
||||
async def request_upload_slots(self, recipient_jid, attachments):
|
||||
"""Requests upload slots for multiple attachments concurrently."""
|
||||
@@ -898,7 +1071,7 @@ class XMPPComponent(ComponentXMPP):
|
||||
|
||||
# Step 2: Request upload slots concurrently
|
||||
valid_uploads = await self.request_upload_slots(recipient_jid, attachments)
|
||||
self.log.info(f"Got upload slots")
|
||||
self.log.info("Got upload slots")
|
||||
if not valid_uploads:
|
||||
self.log.warning("No valid upload slots obtained.")
|
||||
# return
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import asyncio
|
||||
|
||||
import aiomysql
|
||||
|
||||
from core.schemas import mc_s
|
||||
@@ -41,7 +43,10 @@ async def create_index():
|
||||
for name, schema in schemas.items():
|
||||
schema_types = ", ".join([f"{k} {v}" for k, v in schema.items()])
|
||||
|
||||
create_query = f"create table if not exists {name}({schema_types}) engine='columnar'"
|
||||
create_query = (
|
||||
f"create table if not exists {name}({schema_types}) "
|
||||
"engine='columnar'"
|
||||
)
|
||||
log.info(f"Schema types {create_query}")
|
||||
await cur.execute(create_query) # SQLi
|
||||
except aiomysql.Error as e:
|
||||
|
||||
@@ -80,8 +80,14 @@ class PersonIdentifierForm(RestrictedFormMixin, forms.ModelForm):
|
||||
model = PersonIdentifier
|
||||
fields = ("identifier", "service")
|
||||
help_texts = {
|
||||
"identifier": "The unique identifier (e.g., username or phone number) for the person.",
|
||||
"service": "The platform associated with this identifier (e.g., Signal, Instagram).",
|
||||
"identifier": (
|
||||
"The unique identifier (e.g., username or phone number) "
|
||||
"for the person."
|
||||
),
|
||||
"service": (
|
||||
"The platform associated with this identifier "
|
||||
"(e.g., Signal, WhatsApp, Instagram, XMPP)."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -153,16 +159,27 @@ class PersonaForm(RestrictedFormMixin, forms.ModelForm):
|
||||
help_texts = {
|
||||
"alias": "The preferred name or identity for this persona.",
|
||||
"mbti": "Select the Myers-Briggs Type Indicator (MBTI) personality type.",
|
||||
"mbti_identity": "Identity assertiveness: -1 (Turbulent) to +1 (Assertive).",
|
||||
"mbti_identity": (
|
||||
"Identity assertiveness: -1 (Turbulent) to +1 (Assertive)."
|
||||
),
|
||||
"inner_story": "A brief background or philosophy that shapes this persona.",
|
||||
"core_values": "The guiding principles and values of this persona.",
|
||||
"communication_style": "How this persona prefers to communicate (e.g., direct, formal, casual).",
|
||||
"communication_style": (
|
||||
"How this persona prefers to communicate "
|
||||
"(e.g., direct, formal, casual)."
|
||||
),
|
||||
"flirting_style": "How this persona expresses attraction.",
|
||||
"humor_style": "Preferred style of humor (e.g., dry, dark, sarcastic).",
|
||||
"likes": "Topics and things this persona enjoys discussing.",
|
||||
"dislikes": "Topics and behaviors this persona prefers to avoid.",
|
||||
"tone": "The general tone this persona prefers (e.g., formal, witty, detached).",
|
||||
"response_tactics": "How this persona handles manipulation (e.g., gaslighting, guilt-tripping).",
|
||||
"tone": (
|
||||
"The general tone this persona prefers "
|
||||
"(e.g., formal, witty, detached)."
|
||||
),
|
||||
"response_tactics": (
|
||||
"How this persona handles manipulation "
|
||||
"(e.g., gaslighting, guilt-tripping)."
|
||||
),
|
||||
"persuasion_tactics": "The methods this persona uses to convince others.",
|
||||
"boundaries": "What this persona will not tolerate in conversations.",
|
||||
"trust": "Initial trust level (0-100) given in interactions.",
|
||||
@@ -182,7 +199,9 @@ class ManipulationForm(RestrictedFormMixin, forms.ModelForm):
|
||||
"persona": "The persona used for this manipulation.",
|
||||
"enabled": "Whether this manipulation is enabled.",
|
||||
"mode": "Mode of operation.",
|
||||
"filter_enabled": "Whether incoming messages will be filtered using the persona.",
|
||||
"filter_enabled": (
|
||||
"Whether incoming messages will be filtered using the persona."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -199,12 +218,27 @@ class SessionForm(RestrictedFormMixin, forms.ModelForm):
|
||||
class MessageForm(RestrictedFormMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = Message
|
||||
fields = ("session", "sender_uuid", "text", "custom_author")
|
||||
fields = (
|
||||
"session",
|
||||
"sender_uuid",
|
||||
"text",
|
||||
"custom_author",
|
||||
"delivered_ts",
|
||||
"read_ts",
|
||||
"read_source_service",
|
||||
"read_by_identifier",
|
||||
"receipt_payload",
|
||||
)
|
||||
help_texts = {
|
||||
"session": "Chat session this message was sent in.",
|
||||
"sender_uuid": "UUID of the sender.",
|
||||
"text": "Content of the message.",
|
||||
"custom_author": "For detecting USER and BOT messages.",
|
||||
"delivered_ts": "Delivery timestamp in unix milliseconds, if known.",
|
||||
"read_ts": "Read timestamp in unix milliseconds, if known.",
|
||||
"read_source_service": "Service that reported this read receipt.",
|
||||
"read_by_identifier": "Identifier that read this message.",
|
||||
"receipt_payload": "Raw normalized receipt metadata.",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ from typing import Annotated, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
from django.utils import timezone as dj_timezone
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from core.clients import signal, signalapi
|
||||
from core.lib.prompts.functions import delete_messages
|
||||
from core.models import Message, PersonIdentifier, QueuedMessage, User
|
||||
from core.clients import serviceapi
|
||||
from core.messaging import natural
|
||||
from core.models import Message, PersonIdentifier, QueuedMessage
|
||||
from core.util import logs
|
||||
|
||||
log = logs.get_logger("deferred")
|
||||
@@ -34,12 +34,23 @@ class DeferredRequest(BaseModel):
|
||||
|
||||
|
||||
async def send_message(db_obj):
|
||||
recipient_uuid = db_obj.session.identifier.identifier
|
||||
identifier = db_obj.session.identifier
|
||||
recipient_uuid = identifier.identifier
|
||||
service = identifier.service
|
||||
text = db_obj.text
|
||||
|
||||
send = lambda x: signalapi.send_message_raw(recipient_uuid, x) # returns ts
|
||||
start_t = lambda: signalapi.start_typing(recipient_uuid)
|
||||
stop_t = lambda: signalapi.stop_typing(recipient_uuid)
|
||||
async def send(value):
|
||||
return await serviceapi.send_message_raw(
|
||||
service,
|
||||
recipient_uuid,
|
||||
value,
|
||||
) # returns ts
|
||||
|
||||
async def start_t():
|
||||
return await serviceapi.start_typing(service, recipient_uuid)
|
||||
|
||||
async def stop_t():
|
||||
return await serviceapi.stop_typing(service, recipient_uuid)
|
||||
|
||||
tss = await natural.natural_send_message(
|
||||
text,
|
||||
@@ -52,13 +63,17 @@ async def send_message(db_obj):
|
||||
result = [x for x in tss if x] # all trueish ts
|
||||
if result: # if at least one message was sent
|
||||
ts1 = result.pop() # pick a time
|
||||
log.info(f"signal message create {text}")
|
||||
if isinstance(ts1, bool):
|
||||
ts1 = int(dj_timezone.now().timestamp() * 1000)
|
||||
log.info("Stored outbound message for %s: %s", service, text)
|
||||
await sync_to_async(Message.objects.create)(
|
||||
user=db_obj.session.user,
|
||||
session=db_obj.session,
|
||||
custom_author="BOT",
|
||||
text=text,
|
||||
ts=ts1, # use that time in db
|
||||
delivered_ts=ts1,
|
||||
read_source_service=service,
|
||||
)
|
||||
|
||||
|
||||
@@ -86,12 +101,7 @@ async def process_deferred(data: dict, **kwargs):
|
||||
log.info(f"Didn't get message from {message_id}")
|
||||
return
|
||||
|
||||
if message.session.identifier.service == "signal":
|
||||
await send_message(message)
|
||||
|
||||
else:
|
||||
log.warning(f"Protocol not supported: {message.session.identifier.service}")
|
||||
return
|
||||
await send_message(message)
|
||||
elif method == "xmpp": # send xmpp message
|
||||
xmpp = kwargs.get("xmpp")
|
||||
service = validated_data.service
|
||||
@@ -107,12 +117,16 @@ async def process_deferred(data: dict, **kwargs):
|
||||
# attachments = []
|
||||
|
||||
# Asynchronously fetch all attachments
|
||||
tasks = [signalapi.fetch_signal_attachment(att["id"]) for att in attachments]
|
||||
tasks = [serviceapi.fetch_attachment(service, att) for att in attachments]
|
||||
fetched_attachments = await asyncio.gather(*tasks)
|
||||
|
||||
for fetched, att in zip(fetched_attachments, attachments):
|
||||
if not fetched:
|
||||
log.warning(f"Failed to fetch attachment {att['id']} from Signal.")
|
||||
log.warning(
|
||||
"Failed to fetch attachment %s from %s.",
|
||||
att.get("id"),
|
||||
service,
|
||||
)
|
||||
continue
|
||||
|
||||
# Attach fetched file to XMPP
|
||||
@@ -129,7 +143,9 @@ async def process_deferred(data: dict, **kwargs):
|
||||
user = identifier.user
|
||||
|
||||
log.info(
|
||||
f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP."
|
||||
"Sending %s attachments from %s to XMPP.",
|
||||
len(xmpp_attachments),
|
||||
service,
|
||||
)
|
||||
await xmpp.send_from_external(
|
||||
user,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from core.util import logs
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from core.modules.router import UnifiedRouter
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from openai import AsyncOpenAI, OpenAI
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from core.models import AI, ChatSession, Manipulation, Message, Person
|
||||
from core.models import AI
|
||||
|
||||
|
||||
async def run_prompt(
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.utils import timezone
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from core.lib.prompts import bases
|
||||
from core.models import AI, ChatSession, Manipulation, Message, Person
|
||||
from core.util import logs
|
||||
from core.models import AI, Manipulation, Person
|
||||
|
||||
|
||||
def generate_prompt(msg: dict, person: Person, manip: Manipulation, chat_history: str):
|
||||
@@ -62,7 +55,7 @@ async def run_context_prompt(
|
||||
):
|
||||
cast = {"api_key": ai.api_key}
|
||||
if ai.base_url is not None:
|
||||
cast["api_key"] = ai.base_url
|
||||
cast["base_url"] = ai.base_url
|
||||
client = AsyncOpenAI(**cast)
|
||||
response = await client.chat.completions.create(
|
||||
model=ai.model,
|
||||
|
||||
@@ -15,7 +15,11 @@ log = logs.get_logger("history")
|
||||
DEFAULT_PROMPT_HISTORY_MAX_MESSAGES = getattr(
|
||||
settings, "PROMPT_HISTORY_MAX_MESSAGES", 120
|
||||
)
|
||||
DEFAULT_PROMPT_HISTORY_MAX_CHARS = getattr(settings, "PROMPT_HISTORY_MAX_CHARS", 24000)
|
||||
DEFAULT_PROMPT_HISTORY_MAX_CHARS = getattr(
|
||||
settings,
|
||||
"PROMPT_HISTORY_MAX_CHARS",
|
||||
24000,
|
||||
)
|
||||
DEFAULT_PROMPT_HISTORY_MIN_MESSAGES = getattr(
|
||||
settings, "PROMPT_HISTORY_MIN_MESSAGES", 24
|
||||
)
|
||||
@@ -40,7 +44,8 @@ def _build_recent_history(messages, max_chars):
|
||||
total_chars = 0
|
||||
# Recency-first packing, then reorder to chronological output later.
|
||||
for msg in reversed(messages):
|
||||
line = f"[{msg.ts}] <{msg.custom_author if msg.custom_author else msg.session.identifier.person.name}> {msg.text}"
|
||||
author = msg.custom_author or msg.session.identifier.person.name
|
||||
line = f"[{msg.ts}] <{author}> {msg.text}"
|
||||
line_len = len(line) + 1
|
||||
# Keep at least one line even if it alone exceeds max_chars.
|
||||
if selected and (total_chars + line_len) > max_chars:
|
||||
@@ -147,6 +152,7 @@ async def store_message(session, sender, text, ts, outgoing=False):
|
||||
sender_uuid=sender,
|
||||
text=text,
|
||||
ts=ts,
|
||||
delivered_ts=ts,
|
||||
custom_author="USER" if outgoing else None,
|
||||
)
|
||||
|
||||
@@ -161,6 +167,7 @@ async def store_own_message(session, text, ts, manip=None, queue=False):
|
||||
"custom_author": "BOT",
|
||||
"text": text,
|
||||
"ts": ts,
|
||||
"delivered_ts": ts,
|
||||
}
|
||||
if queue:
|
||||
msg_object = QueuedMessage
|
||||
@@ -177,3 +184,62 @@ async def store_own_message(session, text, ts, manip=None, queue=False):
|
||||
|
||||
async def delete_queryset(queryset):
|
||||
await sync_to_async(queryset.delete, thread_sensitive=True)()
|
||||
|
||||
|
||||
async def apply_read_receipts(
|
||||
user,
|
||||
identifier,
|
||||
message_timestamps,
|
||||
read_ts=None,
|
||||
source_service="signal",
|
||||
read_by_identifier="",
|
||||
payload=None,
|
||||
):
|
||||
"""
|
||||
Persist delivery/read metadata for one identifier's messages.
|
||||
"""
|
||||
ts_values = []
|
||||
for item in message_timestamps or []:
|
||||
try:
|
||||
ts_values.append(int(item))
|
||||
except Exception:
|
||||
continue
|
||||
if not ts_values:
|
||||
return 0
|
||||
|
||||
try:
|
||||
read_at = int(read_ts) if read_ts else None
|
||||
except Exception:
|
||||
read_at = None
|
||||
|
||||
rows = await sync_to_async(list)(
|
||||
Message.objects.filter(
|
||||
user=user,
|
||||
session__identifier=identifier,
|
||||
ts__in=ts_values,
|
||||
).select_related("session")
|
||||
)
|
||||
updated = 0
|
||||
for message in rows:
|
||||
dirty = []
|
||||
if message.delivered_ts is None:
|
||||
message.delivered_ts = read_at or message.ts
|
||||
dirty.append("delivered_ts")
|
||||
if read_at and (message.read_ts is None or read_at > message.read_ts):
|
||||
message.read_ts = read_at
|
||||
dirty.append("read_ts")
|
||||
if source_service and message.read_source_service != source_service:
|
||||
message.read_source_service = source_service
|
||||
dirty.append("read_source_service")
|
||||
if read_by_identifier and message.read_by_identifier != read_by_identifier:
|
||||
message.read_by_identifier = read_by_identifier
|
||||
dirty.append("read_by_identifier")
|
||||
if payload:
|
||||
receipt_data = dict(message.receipt_payload or {})
|
||||
receipt_data[str(source_service)] = payload
|
||||
message.receipt_payload = receipt_data
|
||||
dirty.append("receipt_payload")
|
||||
if dirty:
|
||||
await sync_to_async(message.save)(update_fields=dirty)
|
||||
updated += 1
|
||||
return updated
|
||||
|
||||
48
core/messaging/media_bridge.py
Normal file
48
core/messaging/media_bridge.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
|
||||
DEFAULT_BLOB_TTL_SECONDS = 60 * 20
|
||||
|
||||
|
||||
def _blob_cache_key(service, digest):
|
||||
return f"gia:media:{service}:{digest}"
|
||||
|
||||
|
||||
def put_blob(service, content, filename, content_type, ttl=DEFAULT_BLOB_TTL_SECONDS):
|
||||
if not content:
|
||||
return None
|
||||
digest = hashlib.sha1(content).hexdigest()
|
||||
key = _blob_cache_key(service, digest)
|
||||
cache.set(
|
||||
key,
|
||||
{
|
||||
"filename": filename or "attachment.bin",
|
||||
"content_type": content_type or "application/octet-stream",
|
||||
"content_b64": base64.b64encode(content).decode("utf-8"),
|
||||
"size": len(content),
|
||||
"stored_at": int(time.time()),
|
||||
},
|
||||
timeout=ttl,
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
def get_blob(key):
|
||||
row = cache.get(key)
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
content = base64.b64decode(row.get("content_b64", ""))
|
||||
except Exception:
|
||||
return None
|
||||
return {
|
||||
"content": content,
|
||||
"filename": row.get("filename") or "attachment.bin",
|
||||
"content_type": row.get("content_type") or "application/octet-stream",
|
||||
"size": row.get("size") or len(content),
|
||||
"stored_at": row.get("stored_at"),
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import random
|
||||
|
||||
|
||||
async def natural_send_message(
|
||||
@@ -11,7 +10,8 @@ async def natural_send_message(
|
||||
Args:
|
||||
chat_session: The active chat session.
|
||||
ts: Timestamp of the message.
|
||||
c: The context or object with `.send()`, `.start_typing()`, and `.stop_typing()` methods.
|
||||
c: The context or object with `.send()`, `.start_typing()`,
|
||||
and `.stop_typing()` methods.
|
||||
text: A string containing multiple messages separated by double newlines (`\n\n`).
|
||||
|
||||
Behavior:
|
||||
@@ -34,7 +34,7 @@ async def natural_send_message(
|
||||
length_factor = len(message) / 25
|
||||
# ~50 chars ≈ +1s processing
|
||||
# ~25 chars ≈ +1s processing
|
||||
natural_delay = min(base_delay + length_factor, 10) # Cap at 5s max
|
||||
natural_delay = min(base_delay + length_factor, 10) # Cap at 10s max
|
||||
|
||||
# Decide when to start thinking *before* typing
|
||||
if not skip_thinking:
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.utils import timezone
|
||||
|
||||
from core.lib.prompts import bases
|
||||
from core.models import AI, ChatSession, Manipulation, Message, Person
|
||||
from core.models import Manipulation, Person
|
||||
from core.util import logs
|
||||
|
||||
log = logs.get_logger("replies")
|
||||
|
||||
@@ -2,13 +2,26 @@ from asgiref.sync import sync_to_async
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def messages_to_string(messages: list):
|
||||
def messages_to_string(messages: list, author_rewrites: dict | None = None):
|
||||
"""
|
||||
Converts message objects to a formatted string, showing custom_author if set.
|
||||
"""
|
||||
author_rewrites = {
|
||||
str(key).strip().upper(): str(value)
|
||||
for key, value in (author_rewrites or {}).items()
|
||||
}
|
||||
|
||||
def _author_label(message):
|
||||
author = (
|
||||
message.custom_author
|
||||
if message.custom_author
|
||||
else message.session.identifier.person.name
|
||||
)
|
||||
mapped = author_rewrites.get(str(author).strip().upper())
|
||||
return mapped if mapped else author
|
||||
|
||||
message_texts = [
|
||||
f"[{msg.ts}] <{msg.custom_author if msg.custom_author else msg.session.identifier.person.name}> {msg.text}"
|
||||
for msg in messages
|
||||
f"[{msg.ts}] <{_author_label(msg)}> {msg.text}" for msg in messages
|
||||
]
|
||||
return "\n".join(message_texts)
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-14 22:52
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-15 00:14
|
||||
|
||||
import core.models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import core.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-15 00:58
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-15 01:13
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-15 02:38
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-15 15:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0022_patternmitigationautosettings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='delivered_ts',
|
||||
field=models.BigIntegerField(blank=True, help_text='Delivery timestamp (unix ms) when known.', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='read_by_identifier',
|
||||
field=models.CharField(blank=True, help_text='Identifier that read this message (service-native value).', max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='read_source_service',
|
||||
field=models.CharField(blank=True, choices=[('signal', 'Signal'), ('whatsapp', 'WhatsApp'), ('xmpp', 'XMPP'), ('instagram', 'Instagram')], help_text='Service that reported the read receipt.', max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='read_ts',
|
||||
field=models.BigIntegerField(blank=True, help_text='Read timestamp (unix ms) when known.', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='receipt_payload',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Raw normalized delivery/read receipt metadata.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='messageevent',
|
||||
name='source_system',
|
||||
field=models.CharField(choices=[('signal', 'Signal'), ('whatsapp', 'WhatsApp'), ('xmpp', 'XMPP'), ('instagram', 'Instagram'), ('workspace', 'Workspace'), ('ai', 'AI')], default='signal', help_text='System that produced this event record.', max_length=32),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='personidentifier',
|
||||
name='service',
|
||||
field=models.CharField(choices=[('signal', 'Signal'), ('whatsapp', 'WhatsApp'), ('xmpp', 'XMPP'), ('instagram', 'Instagram')], max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='workspaceconversation',
|
||||
name='platform_type',
|
||||
field=models.CharField(choices=[('signal', 'Signal'), ('whatsapp', 'WhatsApp'), ('xmpp', 'XMPP'), ('instagram', 'Instagram')], default='signal', help_text='Primary transport for this conversation (reuses SERVICE_CHOICES).', max_length=255),
|
||||
),
|
||||
]
|
||||
45
core/migrations/0024_workspacemetricsnapshot.py
Normal file
45
core/migrations/0024_workspacemetricsnapshot.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-15 16:55
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0023_message_delivered_ts_message_read_by_identifier_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WorkspaceMetricSnapshot',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('computed_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='When this snapshot was persisted.')),
|
||||
('source_event_ts', models.BigIntegerField(blank=True, help_text='Latest message timestamp used during this metric computation.', null=True)),
|
||||
('stability_state', models.CharField(choices=[('calibrating', 'Calibrating'), ('stable', 'Stable'), ('watch', 'Watch'), ('fragile', 'Fragile')], default='calibrating', help_text='Stability state at computation time.', max_length=32)),
|
||||
('stability_score', models.FloatField(blank=True, help_text='Stability score (0-100).', null=True)),
|
||||
('stability_confidence', models.FloatField(default=0.0, help_text='Confidence in stability score (0.0-1.0).')),
|
||||
('stability_sample_messages', models.PositiveIntegerField(default=0, help_text='How many messages were in the sampled window.')),
|
||||
('stability_sample_days', models.PositiveIntegerField(default=0, help_text='How many days were in the sampled window.')),
|
||||
('commitment_inbound_score', models.FloatField(blank=True, help_text='Commitment estimate counterpart -> user (0-100).', null=True)),
|
||||
('commitment_outbound_score', models.FloatField(blank=True, help_text='Commitment estimate user -> counterpart (0-100).', null=True)),
|
||||
('commitment_confidence', models.FloatField(default=0.0, help_text='Confidence in commitment scores (0.0-1.0).')),
|
||||
('inbound_messages', models.PositiveIntegerField(default=0, help_text='Inbound message count in the sampled window.')),
|
||||
('outbound_messages', models.PositiveIntegerField(default=0, help_text='Outbound message count in the sampled window.')),
|
||||
('reciprocity_score', models.FloatField(blank=True, help_text='Balance component used for stability.', null=True)),
|
||||
('continuity_score', models.FloatField(blank=True, help_text='Continuity component used for stability.', null=True)),
|
||||
('response_score', models.FloatField(blank=True, help_text='Response-time component used for stability.', null=True)),
|
||||
('volatility_score', models.FloatField(blank=True, help_text='Volatility component used for stability.', null=True)),
|
||||
('inbound_response_score', models.FloatField(blank=True, help_text='Inbound response-lag score used for commitment.', null=True)),
|
||||
('outbound_response_score', models.FloatField(blank=True, help_text='Outbound response-lag score used for commitment.', null=True)),
|
||||
('balance_inbound_score', models.FloatField(blank=True, help_text='Inbound balance score used for commitment.', null=True)),
|
||||
('balance_outbound_score', models.FloatField(blank=True, help_text='Outbound balance score used for commitment.', null=True)),
|
||||
('conversation', models.ForeignKey(help_text='Workspace conversation this metric snapshot belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='metric_snapshots', to='core.workspaceconversation')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-computed_at',),
|
||||
'indexes': [models.Index(fields=['conversation', 'computed_at'], name='core_worksp_convers_4ee793_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
203
core/models.py
203
core/models.py
@@ -1,19 +1,19 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
|
||||
from core.clients import signalapi
|
||||
from core.clients import transport
|
||||
from core.lib.notify import raw_sendmsg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_CHOICES = (
|
||||
("signal", "Signal"),
|
||||
("whatsapp", "WhatsApp"),
|
||||
("xmpp", "XMPP"),
|
||||
("instagram", "Instagram"),
|
||||
)
|
||||
@@ -61,7 +61,7 @@ def _attribute_display_id(kind, *parts):
|
||||
n_letters //= 26
|
||||
|
||||
digits = int(digest[8:16], 16) % 10000
|
||||
return f"{''.join(letters)}{digits:04d}"
|
||||
return f"{''.join(letters)}{str(digits).zfill(4)}"
|
||||
|
||||
|
||||
def get_default_workspace_user_pk():
|
||||
@@ -157,20 +157,16 @@ class PersonIdentifier(models.Model):
|
||||
def __str__(self):
|
||||
return f"{self.person} ({self.service})"
|
||||
|
||||
async def send(self, text, attachments=[]):
|
||||
async def send(self, text, attachments=None):
|
||||
"""
|
||||
Send this contact a text.
|
||||
"""
|
||||
if self.service == "signal":
|
||||
ts = await signalapi.send_message_raw(
|
||||
self.identifier,
|
||||
text,
|
||||
attachments,
|
||||
)
|
||||
print("SENT")
|
||||
return ts
|
||||
else:
|
||||
raise NotImplementedError(f"Service not implemented: {self.service}")
|
||||
return await transport.send_message_raw(
|
||||
self.service,
|
||||
self.identifier,
|
||||
text=text,
|
||||
attachments=attachments or [],
|
||||
)
|
||||
|
||||
|
||||
class ChatSession(models.Model):
|
||||
@@ -214,6 +210,34 @@ class Message(models.Model):
|
||||
text = models.TextField(blank=True, null=True)
|
||||
|
||||
custom_author = models.CharField(max_length=255, blank=True, null=True)
|
||||
delivered_ts = models.BigIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Delivery timestamp (unix ms) when known.",
|
||||
)
|
||||
read_ts = models.BigIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Read timestamp (unix ms) when known.",
|
||||
)
|
||||
read_source_service = models.CharField(
|
||||
max_length=255,
|
||||
choices=SERVICE_CHOICES,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Service that reported the read receipt.",
|
||||
)
|
||||
read_by_identifier = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Identifier that read this message (service-native value).",
|
||||
)
|
||||
receipt_payload = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Raw normalized delivery/read receipt metadata.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["ts"]
|
||||
@@ -471,14 +495,130 @@ class WorkspaceConversation(models.Model):
|
||||
return self.title or f"{self.platform_type}:{self.id}"
|
||||
|
||||
|
||||
class WorkspaceMetricSnapshot(models.Model):
|
||||
"""
|
||||
Historical snapshots of workspace metrics for trend visualisation.
|
||||
"""
|
||||
|
||||
conversation = models.ForeignKey(
|
||||
WorkspaceConversation,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="metric_snapshots",
|
||||
help_text="Workspace conversation this metric snapshot belongs to.",
|
||||
)
|
||||
computed_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
db_index=True,
|
||||
help_text="When this snapshot was persisted.",
|
||||
)
|
||||
source_event_ts = models.BigIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Latest message timestamp used during this metric computation.",
|
||||
)
|
||||
stability_state = models.CharField(
|
||||
max_length=32,
|
||||
choices=WorkspaceConversation.StabilityState.choices,
|
||||
default=WorkspaceConversation.StabilityState.CALIBRATING,
|
||||
help_text="Stability state at computation time.",
|
||||
)
|
||||
stability_score = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Stability score (0-100).",
|
||||
)
|
||||
stability_confidence = models.FloatField(
|
||||
default=0.0,
|
||||
help_text="Confidence in stability score (0.0-1.0).",
|
||||
)
|
||||
stability_sample_messages = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="How many messages were in the sampled window.",
|
||||
)
|
||||
stability_sample_days = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="How many days were in the sampled window.",
|
||||
)
|
||||
commitment_inbound_score = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Commitment estimate counterpart -> user (0-100).",
|
||||
)
|
||||
commitment_outbound_score = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Commitment estimate user -> counterpart (0-100).",
|
||||
)
|
||||
commitment_confidence = models.FloatField(
|
||||
default=0.0,
|
||||
help_text="Confidence in commitment scores (0.0-1.0).",
|
||||
)
|
||||
inbound_messages = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Inbound message count in the sampled window.",
|
||||
)
|
||||
outbound_messages = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Outbound message count in the sampled window.",
|
||||
)
|
||||
reciprocity_score = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Balance component used for stability.",
|
||||
)
|
||||
continuity_score = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Continuity component used for stability.",
|
||||
)
|
||||
response_score = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Response-time component used for stability.",
|
||||
)
|
||||
volatility_score = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Volatility component used for stability.",
|
||||
)
|
||||
inbound_response_score = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Inbound response-lag score used for commitment.",
|
||||
)
|
||||
outbound_response_score = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Outbound response-lag score used for commitment.",
|
||||
)
|
||||
balance_inbound_score = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Inbound balance score used for commitment.",
|
||||
)
|
||||
balance_outbound_score = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Outbound balance score used for commitment.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ("-computed_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["conversation", "computed_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Metrics {self.conversation_id} @ {self.computed_at.isoformat()}"
|
||||
|
||||
|
||||
class MessageEvent(models.Model):
|
||||
"""
|
||||
Normalized message event used by workspace timeline and AI selection windows.
|
||||
"""
|
||||
|
||||
SOURCE_SYSTEM_CHOICES = (
|
||||
("signal", "Signal"),
|
||||
("xmpp", "XMPP"),
|
||||
*SERVICE_CHOICES,
|
||||
("workspace", "Workspace"),
|
||||
("ai", "AI"),
|
||||
)
|
||||
@@ -499,7 +639,10 @@ class MessageEvent(models.Model):
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_message_events",
|
||||
default=get_default_workspace_user_pk,
|
||||
help_text="Owner of this message event row (required for restricted CRUD filtering).",
|
||||
help_text=(
|
||||
"Owner of this message event row "
|
||||
"(required for restricted CRUD filtering)."
|
||||
),
|
||||
)
|
||||
conversation = models.ForeignKey(
|
||||
WorkspaceConversation,
|
||||
@@ -679,7 +822,9 @@ class AIResult(models.Model):
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_ai_results",
|
||||
default=get_default_workspace_user_pk,
|
||||
help_text="Owner of this AI result row (required for restricted CRUD filtering).",
|
||||
help_text=(
|
||||
"Owner of this AI result row " "(required for restricted CRUD filtering)."
|
||||
),
|
||||
)
|
||||
ai_request = models.OneToOneField(
|
||||
AIRequest,
|
||||
@@ -702,7 +847,8 @@ class AIResult(models.Model):
|
||||
blank=True,
|
||||
help_text=(
|
||||
"Structured positive/neutral/risk signals inferred for this run. "
|
||||
"Example item: {'label':'repair_attempt','valence':'positive','message_event_ids':[...]}."
|
||||
"Example item: {'label':'repair_attempt','valence':'positive',"
|
||||
"'message_event_ids':[...]}."
|
||||
),
|
||||
)
|
||||
memory_proposals = models.JSONField(
|
||||
@@ -1089,7 +1235,8 @@ class PatternMitigationCorrection(models.Model):
|
||||
default="",
|
||||
help_text=(
|
||||
"Joint clarification text intended to reduce interpretation drift. "
|
||||
"Example: 'When you say \"you ignore me\", I hear fear of disconnection, not blame.'"
|
||||
'Example: \'When you say "you ignore me", I hear fear of '
|
||||
"disconnection, not blame.'"
|
||||
),
|
||||
)
|
||||
source_phrase = models.TextField(
|
||||
@@ -1097,7 +1244,8 @@ class PatternMitigationCorrection(models.Model):
|
||||
default="",
|
||||
help_text=(
|
||||
"Situation/message fragment this correction responds to. "
|
||||
"Example: 'she says: \"you never listen\"' or 'you say: \"you are dismissing me\"'."
|
||||
"Example: 'she says: \"you never listen\"' or "
|
||||
"'you say: \"you are dismissing me\"'."
|
||||
),
|
||||
)
|
||||
perspective = models.CharField(
|
||||
@@ -1106,14 +1254,18 @@ class PatternMitigationCorrection(models.Model):
|
||||
default="third_person",
|
||||
help_text=(
|
||||
"Narrative perspective used when framing this correction. "
|
||||
"Examples: third person ('she says'), second person ('you say'), first person ('I say')."
|
||||
"Examples: third person ('she says'), second person ('you say'), "
|
||||
"first person ('I say')."
|
||||
),
|
||||
)
|
||||
share_target = models.CharField(
|
||||
max_length=16,
|
||||
choices=SHARE_TARGET_CHOICES,
|
||||
default="both",
|
||||
help_text="Who this insight is intended to be shared with. Example: self, other, or both.",
|
||||
help_text=(
|
||||
"Who this insight is intended to be shared with. "
|
||||
"Example: self, other, or both."
|
||||
),
|
||||
)
|
||||
language_style = models.CharField(
|
||||
max_length=16,
|
||||
@@ -1121,7 +1273,8 @@ class PatternMitigationCorrection(models.Model):
|
||||
default="adapted",
|
||||
help_text=(
|
||||
"Whether to keep wording identical or adapt it per recipient. "
|
||||
"Example: same text for both parties, or softened/adapted wording for recipient."
|
||||
"Example: same text for both parties, or softened/adapted wording "
|
||||
"for recipient."
|
||||
),
|
||||
)
|
||||
enabled = models.BooleanField(
|
||||
|
||||
54
core/modules/mixed_protocol.py
Normal file
54
core/modules/mixed_protocol.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UnifiedEvent:
|
||||
"""
|
||||
Normalized event envelope shared across protocol adapters.
|
||||
"""
|
||||
|
||||
service: str
|
||||
event_type: str
|
||||
identifier: str = ""
|
||||
text: str = ""
|
||||
ts: int | None = None
|
||||
message_timestamps: list[int] = field(default_factory=list)
|
||||
attachments: list[dict[str, Any]] = field(default_factory=list)
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def normalize_gateway_event(service: str, payload: dict[str, Any]) -> UnifiedEvent:
|
||||
event_type = str(payload.get("type") or "").strip().lower()
|
||||
message_timestamps = []
|
||||
raw_timestamps = payload.get("message_timestamps") or payload.get("timestamps") or []
|
||||
if isinstance(raw_timestamps, list):
|
||||
for item in raw_timestamps:
|
||||
try:
|
||||
message_timestamps.append(int(item))
|
||||
except Exception:
|
||||
continue
|
||||
elif raw_timestamps:
|
||||
try:
|
||||
message_timestamps = [int(raw_timestamps)]
|
||||
except Exception:
|
||||
message_timestamps = []
|
||||
|
||||
ts = payload.get("ts") or payload.get("timestamp")
|
||||
try:
|
||||
ts = int(ts) if ts is not None else None
|
||||
except Exception:
|
||||
ts = None
|
||||
|
||||
return UnifiedEvent(
|
||||
service=service,
|
||||
event_type=event_type,
|
||||
identifier=str(
|
||||
payload.get("identifier") or payload.get("source") or payload.get("from") or ""
|
||||
).strip(),
|
||||
text=str(payload.get("text") or ""),
|
||||
ts=ts,
|
||||
message_timestamps=message_timestamps,
|
||||
attachments=list(payload.get("attachments") or []),
|
||||
payload=dict(payload or {}),
|
||||
)
|
||||
@@ -1,5 +1,12 @@
|
||||
from asgiref.sync import sync_to_async
|
||||
|
||||
from core.clients import transport
|
||||
from core.clients.instagram import InstagramClient
|
||||
from core.clients.signal import SignalClient
|
||||
from core.clients.whatsapp import WhatsAppClient
|
||||
from core.clients.xmpp import XMPPClient
|
||||
from core.messaging import history
|
||||
from core.models import PersonIdentifier
|
||||
from core.util import logs
|
||||
|
||||
|
||||
@@ -16,11 +23,15 @@ class UnifiedRouter(object):
|
||||
|
||||
self.xmpp = XMPPClient(self, loop, "xmpp")
|
||||
self.signal = SignalClient(self, loop, "signal")
|
||||
self.whatsapp = WhatsAppClient(self, loop, "whatsapp")
|
||||
self.instagram = InstagramClient(self, loop, "instagram")
|
||||
|
||||
def _start(self):
|
||||
print("UR _start")
|
||||
self.xmpp.start()
|
||||
self.signal.start()
|
||||
self.whatsapp.start()
|
||||
self.instagram.start()
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
@@ -37,14 +48,66 @@ class UnifiedRouter(object):
|
||||
async def message_received(self, protocol, *args, **kwargs):
|
||||
self.log.info(f"Message received ({protocol}) {args} {kwargs}")
|
||||
|
||||
async def _resolve_identifier_objects(self, protocol, identifier):
|
||||
if isinstance(identifier, PersonIdentifier):
|
||||
return [identifier]
|
||||
value = str(identifier or "").strip()
|
||||
if not value:
|
||||
return []
|
||||
return await sync_to_async(list)(
|
||||
PersonIdentifier.objects.filter(
|
||||
identifier=value,
|
||||
service=protocol,
|
||||
)
|
||||
)
|
||||
|
||||
async def message_read(self, protocol, *args, **kwargs):
|
||||
self.log.info(f"Message read ({protocol}) {args} {kwargs}")
|
||||
identifier = kwargs.get("identifier")
|
||||
timestamps = kwargs.get("message_timestamps") or []
|
||||
read_ts = kwargs.get("read_ts")
|
||||
payload = kwargs.get("payload") or {}
|
||||
read_by = kwargs.get("read_by") or ""
|
||||
|
||||
identifiers = await self._resolve_identifier_objects(protocol, identifier)
|
||||
for row in identifiers:
|
||||
await history.apply_read_receipts(
|
||||
user=row.user,
|
||||
identifier=row,
|
||||
message_timestamps=timestamps,
|
||||
read_ts=read_ts,
|
||||
source_service=protocol,
|
||||
read_by_identifier=read_by or row.identifier,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
async def started_typing(self, protocol, *args, **kwargs):
|
||||
self.log.info(f"Started typing ({protocol}) {args} {kwargs}")
|
||||
identifier = kwargs.get("identifier")
|
||||
identifiers = await self._resolve_identifier_objects(protocol, identifier)
|
||||
for src in identifiers:
|
||||
targets = await sync_to_async(list)(
|
||||
PersonIdentifier.objects.filter(
|
||||
user=src.user,
|
||||
person=src.person,
|
||||
).exclude(service=protocol)
|
||||
)
|
||||
for target in targets:
|
||||
await transport.start_typing(target.service, target.identifier)
|
||||
|
||||
async def stopped_typing(self, protocol, *args, **kwargs):
|
||||
self.log.info(f"Stopped typing ({protocol}) {args} {kwargs}")
|
||||
identifier = kwargs.get("identifier")
|
||||
identifiers = await self._resolve_identifier_objects(protocol, identifier)
|
||||
for src in identifiers:
|
||||
targets = await sync_to_async(list)(
|
||||
PersonIdentifier.objects.filter(
|
||||
user=src.user,
|
||||
person=src.person,
|
||||
).exclude(service=protocol)
|
||||
)
|
||||
for target in targets:
|
||||
await transport.stop_typing(target.service, target.identifier)
|
||||
|
||||
async def reacted(self, protocol, *args, **kwargs):
|
||||
self.log.info(f"Reacted ({protocol}) {args} {kwargs}")
|
||||
|
||||
@@ -295,7 +295,10 @@
|
||||
<a class="navbar-item" href="{% url 'signal' %}">
|
||||
Signal
|
||||
</a>
|
||||
<a class="navbar-item" href="#">
|
||||
<a class="navbar-item" href="{% url 'whatsapp' %}">
|
||||
WhatsApp
|
||||
</a>
|
||||
<a class="navbar-item" href="{% url 'instagram' %}">
|
||||
Instagram
|
||||
</a>
|
||||
</div>
|
||||
@@ -308,6 +311,20 @@
|
||||
|
||||
<div class="navbar-end">
|
||||
{% if user.is_authenticated %}
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a
|
||||
class="navbar-link"
|
||||
hx-get="{% url 'compose_contacts_dropdown' %}"
|
||||
hx-target="#nav-compose-contacts"
|
||||
hx-trigger="click once"
|
||||
hx-swap="innerHTML">
|
||||
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
|
||||
<span style="margin-left: 0.35rem;">Message</span>
|
||||
</a>
|
||||
<div class="navbar-dropdown" id="nav-compose-contacts">
|
||||
<a class="navbar-item is-disabled">Load contacts</a>
|
||||
</div>
|
||||
</div>
|
||||
<a class="navbar-item" href="{% url 'ai_workspace' %}">
|
||||
AI
|
||||
</a>
|
||||
|
||||
110
core/templates/pages/ai-workspace-insight-detail.html
Normal file
110
core/templates/pages/ai-workspace-insight-detail.html
Normal file
@@ -0,0 +1,110 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12">
|
||||
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="{{ workspace_url }}">AI Workspace</a></li>
|
||||
<li><a href="{{ graphs_url }}">Insight Graphs</a></li>
|
||||
<li class="is-active"><a aria-current="page">{{ metric.title }}</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="column is-12">
|
||||
<h1 class="title is-4" style="margin-bottom: 0.35rem;">{{ metric.title }}: {{ person.name }}</h1>
|
||||
<p class="is-size-7 has-text-grey">Conversation {{ workspace_conversation.id }}</p>
|
||||
</div>
|
||||
<div class="column is-5">
|
||||
<div class="box">
|
||||
<p class="heading">{{ metric_group.title }}</p>
|
||||
<p class="title is-5" style="margin-bottom: 0.5rem;">{{ metric.title }}</p>
|
||||
<p><strong>Current Value:</strong> {{ metric_value|default:"-" }}</p>
|
||||
<p style="margin-top: 0.65rem;"><strong>How It Is Calculated</strong></p>
|
||||
<p>{{ metric.calculation }}</p>
|
||||
<p style="margin-top: 0.65rem;"><strong>Psychological Interpretation</strong></p>
|
||||
<p>{{ metric.psychology }}</p>
|
||||
{% if metric_psychology_hint %}
|
||||
<article class="message is-info is-light" style="margin-top: 0.75rem;">
|
||||
<div class="message-body">
|
||||
{{ metric_psychology_hint }}
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<a class="button is-light" href="{{ graphs_url }}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
|
||||
<span>All Graphs</span>
|
||||
</a>
|
||||
<a class="button is-light" href="{{ help_url }}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-circle-question"></i></span>
|
||||
<span>Scoring Help</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-7">
|
||||
<div class="box">
|
||||
<p class="heading">History</p>
|
||||
<div style="height: 360px;">
|
||||
<canvas id="metric-detail-chart"></canvas>
|
||||
</div>
|
||||
{% if not graph_points %}
|
||||
<p class="is-size-7 has-text-grey" style="margin-top: 0.65rem;">
|
||||
No historical points yet for this metric.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ graph_points|json_script:"metric-detail-points" }}
|
||||
<script src="{% static 'js/chart.js' %}"></script>
|
||||
<script>
|
||||
(function() {
|
||||
var node = document.getElementById("metric-detail-points");
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
var points = JSON.parse(node.textContent || "[]");
|
||||
var labels = points.map(function(row) {
|
||||
var dt = new Date(row.x);
|
||||
return dt.toLocaleString();
|
||||
});
|
||||
var values = points.map(function(row) {
|
||||
return row.y;
|
||||
});
|
||||
var ctx = document.getElementById("metric-detail-chart");
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
new Chart(ctx.getContext("2d"), {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "{{ metric.title|escapejs }}",
|
||||
data: values,
|
||||
borderColor: "#3273dc",
|
||||
backgroundColor: "rgba(50,115,220,0.12)",
|
||||
borderWidth: 2,
|
||||
pointRadius: 2,
|
||||
tension: 0.28,
|
||||
spanGaps: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
115
core/templates/pages/ai-workspace-insight-graphs.html
Normal file
115
core/templates/pages/ai-workspace-insight-graphs.html
Normal file
@@ -0,0 +1,115 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12">
|
||||
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="{{ workspace_url }}">AI Workspace</a></li>
|
||||
<li class="is-active"><a aria-current="page">Insight Graphs</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="column is-12">
|
||||
<h1 class="title is-4" style="margin-bottom: 0.35rem;">Insight Graphs: {{ person.name }}</h1>
|
||||
<p class="is-size-7 has-text-grey">
|
||||
Historical metrics for workspace {{ workspace_conversation.id }}
|
||||
</p>
|
||||
<div class="buttons are-small" style="margin-top: 0.6rem;">
|
||||
<a class="button is-light" href="{{ help_url }}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-circle-question"></i></span>
|
||||
<span>Scoring Help</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for graph in graph_cards %}
|
||||
<div class="column is-6">
|
||||
<div class="box">
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center" style="margin-bottom: 0.45rem;">
|
||||
<div>
|
||||
<p class="heading">{{ graph.group_title }}</p>
|
||||
<p class="title is-6" style="margin-bottom: 0.2rem;">{{ graph.title }}</p>
|
||||
<p class="is-size-7 has-text-grey">{{ graph.count }} point{{ graph.count|pluralize }}</p>
|
||||
</div>
|
||||
<div class="buttons are-small" style="margin: 0;">
|
||||
<a class="button is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric=graph.slug %}">
|
||||
Detail
|
||||
</a>
|
||||
<a class="button is-light" href="{{ help_url }}#group-{{ graph.group }}">
|
||||
How It Works
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height: 250px;">
|
||||
<canvas id="graph-{{ graph.slug }}"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{{ graph_cards|json_script:"insight-graph-cards" }}
|
||||
<script src="{% static 'js/chart.js' %}"></script>
|
||||
<script>
|
||||
(function() {
|
||||
var node = document.getElementById("insight-graph-cards");
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
var graphs = JSON.parse(node.textContent || "[]");
|
||||
var palette = ["#3273dc", "#23d160", "#ffdd57", "#ff3860", "#7957d5", "#00d1b2"];
|
||||
|
||||
graphs.forEach(function(graph, idx) {
|
||||
var canvas = document.getElementById("graph-" + graph.slug);
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
var labels = (graph.points || []).map(function(row) {
|
||||
return new Date(row.x).toLocaleString();
|
||||
});
|
||||
var values = (graph.points || []).map(function(row) {
|
||||
return row.y;
|
||||
});
|
||||
var color = palette[idx % palette.length];
|
||||
var yScale = {};
|
||||
if (graph.y_min !== null && graph.y_min !== undefined) {
|
||||
yScale.min = graph.y_min;
|
||||
}
|
||||
if (graph.y_max !== null && graph.y_max !== undefined) {
|
||||
yScale.max = graph.y_max;
|
||||
}
|
||||
new Chart(canvas.getContext("2d"), {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: graph.title,
|
||||
data: values,
|
||||
borderColor: color,
|
||||
backgroundColor: color + "33",
|
||||
borderWidth: 2,
|
||||
pointRadius: 2,
|
||||
tension: 0.24,
|
||||
spanGaps: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: yScale
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
43
core/templates/pages/ai-workspace-insight-help.html
Normal file
43
core/templates/pages/ai-workspace-insight-help.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12">
|
||||
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="{{ workspace_url }}">AI Workspace</a></li>
|
||||
<li><a href="{{ graphs_url }}">Insight Graphs</a></li>
|
||||
<li class="is-active"><a aria-current="page">Scoring Help</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="column is-12">
|
||||
<h1 class="title is-4" style="margin-bottom: 0.35rem;">Scoring Help: {{ person.name }}</h1>
|
||||
<p class="is-size-7 has-text-grey">
|
||||
Combined explanation for each metric collection group and what it can
|
||||
imply in relationship dynamics.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% for group_key, group in groups.items %}
|
||||
<div class="column is-12" id="group-{{ group_key }}">
|
||||
<div class="box">
|
||||
<p class="heading">{{ group.title }}</p>
|
||||
<p style="margin-bottom: 0.75rem;">{{ group.summary }}</p>
|
||||
|
||||
{% for metric in metrics %}
|
||||
{% if metric.group == group_key %}
|
||||
<article class="message is-light" style="margin-bottom: 0.6rem;">
|
||||
<div class="message-body">
|
||||
<p><strong>{{ metric.title }}</strong>: {{ metric.value|default:"-" }}</p>
|
||||
<p><strong>Calculation:</strong> {{ metric.calculation }}</p>
|
||||
<p><strong>Psychological Read:</strong> {{ metric.psychology }}</p>
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -8,4 +8,13 @@
|
||||
hx-trigger="load"
|
||||
hx-swap="afterend"
|
||||
style="display: none;"></div>
|
||||
{% if selected_person_id %}
|
||||
<div
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{% url 'ai_workspace_person' type='widget' person_id=selected_person_id %}"
|
||||
hx-target="#widgets-here"
|
||||
hx-trigger="load delay:250ms"
|
||||
hx-swap="afterend"
|
||||
style="display: none;"></div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
11
core/templates/pages/compose.html
Normal file
11
core/templates/pages/compose.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-10-tablet is-9-desktop is-8-widescreen">
|
||||
<div id="compose-page-panel">
|
||||
{% include "partials/compose-panel.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -3,9 +3,9 @@
|
||||
{% block load_widgets %}
|
||||
<div
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{% url 'signal_accounts' type='widget' %}"
|
||||
hx-get="{% url accounts_url_name type='widget' %}"
|
||||
hx-target="#widgets-here"
|
||||
hx-trigger="load"
|
||||
hx-swap="afterend"
|
||||
style="display: none;"></div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -13,6 +13,37 @@
|
||||
{{ result_text }}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if ai_request_status %}
|
||||
<div class="tags" style="margin-bottom: 0.45rem;">
|
||||
<span class="tag is-light">Request {{ ai_request_status|title }}</span>
|
||||
<span class="tag is-light">Messages {{ ai_request_message_count|default:0 }}</span>
|
||||
{% if ai_result_created_at %}
|
||||
<span class="tag is-light">Result {{ ai_result_created_at }}</span>
|
||||
{% endif %}
|
||||
{% if ai_request_started_at %}
|
||||
<span class="tag is-light">Started {{ ai_request_started_at }}</span>
|
||||
{% endif %}
|
||||
{% if ai_request_finished_at %}
|
||||
<span class="tag is-light">Finished {{ ai_request_finished_at }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if ai_request_window_spec %}
|
||||
<div class="tags" style="margin-bottom: 0.45rem;">
|
||||
{% if ai_request_window_tags %}
|
||||
{% for item in ai_request_window_tags %}
|
||||
<span class="tag is-light">{{ item }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="tag is-light">Window selected</span>
|
||||
{% endif %}
|
||||
{% if ai_request_policy_tags %}
|
||||
{% for item in ai_request_policy_tags %}
|
||||
<span class="tag is-light">{{ item }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if operation == "artifacts" %}
|
||||
{% if latest_plan %}
|
||||
{% include "partials/ai-workspace-mitigation-panel.html" with person=person plan=latest_plan rules=latest_plan_rules games=latest_plan_games corrections=latest_plan_corrections fundamentals_text=latest_plan.fundamental_items|join:"\n" mitigation_messages=latest_plan_messages latest_export=latest_plan_export notice_message=mitigation_notice_message notice_level=mitigation_notice_level auto_settings=latest_auto_settings active_tab="plan_board" %}
|
||||
@@ -145,6 +176,72 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if interaction_signals %}
|
||||
<article class="box" style="padding: 0.55rem; margin-top: 0.55rem; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
|
||||
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.35rem;">Interaction Signals</p>
|
||||
<div class="tags">
|
||||
{% for signal in interaction_signals %}
|
||||
<span class="tag is-light">{{ signal.label }} ({{ signal.valence }})</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
|
||||
{% if memory_proposals %}
|
||||
<article class="box" style="padding: 0.55rem; margin-top: 0.55rem; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
|
||||
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.35rem;">Memory Proposals</p>
|
||||
{% if memory_proposal_groups %}
|
||||
<div class="columns is-multiline" style="margin: 0 -0.25rem;">
|
||||
{% for group in memory_proposal_groups %}
|
||||
<div class="column is-12-mobile is-6-tablet" style="padding: 0.25rem;">
|
||||
<article class="box" style="padding: 0.45rem; margin-bottom: 0; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;">
|
||||
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.3rem;">{{ group.title }}</p>
|
||||
<ul style="margin: 0 0 0.25rem 1.1rem;">
|
||||
{% for proposal in group.items %}
|
||||
<li class="is-size-7" style="margin-bottom: 0.22rem; white-space: pre-wrap;">
|
||||
{{ proposal.content }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% for proposal in memory_proposals %}
|
||||
<p class="is-size-7" style="margin-bottom: 0.3rem; white-space: pre-wrap;">
|
||||
<strong>{{ proposal.kind|title }}</strong>: {{ proposal.content }}
|
||||
</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endif %}
|
||||
|
||||
{% if citations %}
|
||||
<article class="box" style="padding: 0.55rem; margin-top: 0.55rem; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
|
||||
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.35rem;">Citations</p>
|
||||
{% if citation_rows %}
|
||||
<div class="content is-small" style="margin-bottom: 0;">
|
||||
{% for row in citation_rows %}
|
||||
<p class="is-size-7" style="margin-bottom: 0.3rem;">
|
||||
<span class="tag is-light">{{ row.source_system|default:"event" }}</span>
|
||||
{% if row.ts_label %}
|
||||
<span class="has-text-grey">{{ row.ts_label }}</span>
|
||||
{% endif %}
|
||||
{% if row.text %}
|
||||
<span> {{ row.text|truncatechars:140 }}</span>
|
||||
{% else %}
|
||||
<code>{{ row.id }}</code>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="is-size-7" style="margin: 0;">{{ citations|join:", " }}</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endif %}
|
||||
|
||||
{% if operation == "extract_patterns" %}
|
||||
<article class="box" style="padding: 0.7rem; margin-top: 0.65rem; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
|
||||
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.4rem;">Create Framework / Rules / Games</p>
|
||||
|
||||
@@ -7,7 +7,54 @@
|
||||
<p class="is-size-7">{{ plan.objective }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="tag is-light">{{ plan.creation_mode|title }}</span>
|
||||
<div class="is-flex is-flex-direction-column" style="gap: 0.35rem;">
|
||||
<span class="tag is-light">{{ plan.creation_mode|title }} / {{ plan.status|title }}</span>
|
||||
<span class="tag is-light">Created {{ plan.created_at }}</span>
|
||||
<span class="tag is-light">Updated {{ plan.updated_at }}</span>
|
||||
{% if plan.source_ai_result_id %}
|
||||
<span class="tag is-light">Source Result {{ plan.source_ai_result_id }}</span>
|
||||
{% endif %}
|
||||
<form
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-post="{% url 'ai_workspace_mitigation_meta_save' type='widget' person_id=person.id plan_id=plan.id %}"
|
||||
hx-target="#mitigation-shell-{{ person.id }}"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="active_tab" value="{{ active_tab|default:'plan_board' }}">
|
||||
<div class="field" style="margin-bottom: 0.3rem;">
|
||||
<div class="control">
|
||||
<input class="input is-small" type="text" name="title" value="{{ plan.title }}" placeholder="Plan title">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom: 0.3rem;">
|
||||
<div class="control">
|
||||
<textarea class="textarea is-small" rows="2" name="objective" placeholder="Plan objective">{{ plan.objective }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-grouped is-grouped-right" style="margin: 0; gap: 0.3rem;">
|
||||
<div class="control">
|
||||
<div class="select is-small">
|
||||
<select name="creation_mode">
|
||||
{% for value, label in plan_creation_mode_choices %}
|
||||
<option value="{{ value }}" {% if plan.creation_mode == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<div class="select is-small">
|
||||
<select name="status">
|
||||
{% for value, label in plan_status_choices %}
|
||||
<option value="{{ value }}" {% if plan.status == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-small is-light">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if notice_message %}
|
||||
@@ -84,6 +131,7 @@
|
||||
{% for rule in rules %}
|
||||
<article class="box" style="padding: 0.55rem; margin-bottom: 0.45rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;">
|
||||
<span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Rule</span>
|
||||
<span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Created {{ rule.created_at }}</span>
|
||||
<form
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-post="{% url 'ai_workspace_mitigation_artifact_save' type='widget' person_id=person.id plan_id=plan.id kind='rule' artifact_id=rule.id %}"
|
||||
@@ -95,7 +143,10 @@
|
||||
<div class="field" style="margin-bottom: 0.35rem;">
|
||||
<textarea class="textarea is-small" rows="3" name="body" data-editable="1" readonly>{{ rule.content }}</textarea>
|
||||
</div>
|
||||
<input type="hidden" name="enabled" value="1">
|
||||
<label class="checkbox is-size-7" style="margin-bottom: 0.35rem;">
|
||||
<input type="checkbox" name="enabled" value="1" {% if rule.enabled %}checked{% endif %}>
|
||||
Enabled
|
||||
</label>
|
||||
<input type="hidden" name="active_tab" value="{{ active_tab|default:'plan_board' }}">
|
||||
<div class="buttons are-small" style="margin: 0;">
|
||||
<button type="button" class="button is-link is-light" data-edit-state="view" onclick="giaMitigationToggleEdit(this); return false;">Edit</button>
|
||||
@@ -136,6 +187,7 @@
|
||||
{% for game in games %}
|
||||
<article class="box" style="padding: 0.55rem; margin-bottom: 0.45rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;">
|
||||
<span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Game</span>
|
||||
<span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Created {{ game.created_at }}</span>
|
||||
<form
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-post="{% url 'ai_workspace_mitigation_artifact_save' type='widget' person_id=person.id plan_id=plan.id kind='game' artifact_id=game.id %}"
|
||||
@@ -147,7 +199,10 @@
|
||||
<div class="field" style="margin-bottom: 0.35rem;">
|
||||
<textarea class="textarea is-small" rows="3" name="body" data-editable="1" readonly>{{ game.instructions }}</textarea>
|
||||
</div>
|
||||
<input type="hidden" name="enabled" value="1">
|
||||
<label class="checkbox is-size-7" style="margin-bottom: 0.35rem;">
|
||||
<input type="checkbox" name="enabled" value="1" {% if game.enabled %}checked{% endif %}>
|
||||
Enabled
|
||||
</label>
|
||||
<input type="hidden" name="active_tab" value="{{ active_tab|default:'plan_board' }}">
|
||||
<div class="buttons are-small" style="margin: 0;">
|
||||
<button type="button" class="button is-link is-light" data-edit-state="view" onclick="giaMitigationToggleEdit(this); return false;">Edit</button>
|
||||
@@ -203,6 +258,7 @@
|
||||
{% for correction in corrections %}
|
||||
<article class="box" style="padding: 0.55rem; margin-bottom: 0.5rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;">
|
||||
<span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Correction</span>
|
||||
<span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Created {{ correction.created_at }}</span>
|
||||
<form
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-post="{% url 'ai_workspace_mitigation_artifact_save' type='widget' person_id=person.id plan_id=plan.id kind='correction' artifact_id=correction.id %}"
|
||||
@@ -220,8 +276,41 @@
|
||||
<label class="label is-small" style="margin-bottom: 0.2rem;">Insight</label>
|
||||
<textarea class="textarea is-small" rows="2" name="body">{{ correction.clarification }}</textarea>
|
||||
</div>
|
||||
<div class="column is-12-mobile is-4-tablet" style="padding: 0.3rem;">
|
||||
<label class="label is-small" style="margin-bottom: 0.2rem;">Perspective</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="perspective">
|
||||
{% for value, label in correction.PERSPECTIVE_CHOICES %}
|
||||
<option value="{{ value }}" {% if correction.perspective == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-12-mobile is-4-tablet" style="padding: 0.3rem;">
|
||||
<label class="label is-small" style="margin-bottom: 0.2rem;">Share Target</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="share_target">
|
||||
{% for value, label in correction.SHARE_TARGET_CHOICES %}
|
||||
<option value="{{ value }}" {% if correction.share_target == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-12-mobile is-4-tablet" style="padding: 0.3rem;">
|
||||
<label class="label is-small" style="margin-bottom: 0.2rem;">Language Style</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="language_style">
|
||||
{% for value, label in correction.LANGUAGE_STYLE_CHOICES %}
|
||||
<option value="{{ value }}" {% if correction.language_style == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="enabled" value="1">
|
||||
<label class="checkbox is-size-7" style="margin-bottom: 0.35rem;">
|
||||
<input type="checkbox" name="enabled" value="1" {% if correction.enabled %}checked{% endif %}>
|
||||
Enabled
|
||||
</label>
|
||||
<input type="hidden" name="active_tab" value="{{ active_tab|default:'corrections' }}">
|
||||
<div class="buttons are-small" style="margin: 0;">
|
||||
<button class="button is-small is-link is-light">Save Correction</button>
|
||||
@@ -395,6 +484,12 @@
|
||||
<p class="is-size-7" style="margin-bottom: 0;">
|
||||
Last run: {% if auto_settings.last_run_at %}{{ auto_settings.last_run_at }}{% else %}Never{% endif %}
|
||||
</p>
|
||||
<p class="is-size-7" style="margin-bottom: 0;">
|
||||
Created: {{ auto_settings.created_at }} | Updated: {{ auto_settings.updated_at }}
|
||||
</p>
|
||||
<p class="is-size-7" style="margin-bottom: 0;">
|
||||
Last checked event ts: {{ auto_settings.last_checked_event_ts|default:"None" }}
|
||||
</p>
|
||||
{% if auto_settings.last_result_summary %}
|
||||
<p class="is-size-7" style="margin-top: 0.35rem; margin-bottom: 0;">{{ auto_settings.last_result_summary }}</p>
|
||||
{% endif %}
|
||||
@@ -463,10 +558,9 @@
|
||||
<label class="label is-small" style="margin-bottom: 0.25rem;">Bundle</label>
|
||||
<div class="select is-small">
|
||||
<select name="artifact_type">
|
||||
<option value="rulebook">Rulebook</option>
|
||||
<option value="rules">Rules</option>
|
||||
<option value="games">Games</option>
|
||||
<option value="corrections">Corrections</option>
|
||||
{% for value, label in artifact_type_choices %}
|
||||
<option value="{{ value }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -474,9 +568,9 @@
|
||||
<label class="label is-small" style="margin-bottom: 0.25rem;">Format</label>
|
||||
<div class="select is-small">
|
||||
<select name="export_format">
|
||||
<option value="markdown">Markdown</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="text">Text</option>
|
||||
{% for value, label in artifact_format_choices %}
|
||||
<option value="{{ value }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -494,6 +588,11 @@
|
||||
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.3rem;">
|
||||
Last Export: {{ latest_export.artifact_type|title }} ({{ latest_export.export_format|upper }})
|
||||
</p>
|
||||
<p class="is-size-7" style="margin-bottom: 0.3rem;">
|
||||
Created {{ latest_export.created_at }} |
|
||||
Protocol {{ latest_export.protocol_version }} |
|
||||
Meta {{ latest_export.meta }}
|
||||
</p>
|
||||
<pre style="max-height: 14rem; overflow: auto; margin: 0; white-space: pre-wrap; font-size: 0.72rem; line-height: 1.28;">{{ latest_export.payload }}</pre>
|
||||
</article>
|
||||
{% endif %}
|
||||
@@ -505,6 +604,7 @@
|
||||
{% for message in mitigation_messages %}
|
||||
<div style="margin-bottom: 0.45rem;">
|
||||
<span class="tag is-light is-small">{{ message.role }}</span>
|
||||
<span class="tag is-light is-small">{{ message.created_at }}</span>
|
||||
<div style="margin-top: 0.15rem; white-space: pre-wrap;">{{ message.text }}</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
@@ -560,9 +660,9 @@
|
||||
const forceInput = document.getElementById("engage-force-send-" + pid);
|
||||
const sendBtn = document.getElementById("engage-send-btn-" + pid);
|
||||
const force =
|
||||
!!(window.giaWorkspaceState
|
||||
&& window.giaWorkspaceState[pid]
|
||||
&& window.giaWorkspaceState[pid].forceSend);
|
||||
!!(window.giaWorkspaceState
|
||||
&& window.giaWorkspaceState[pid]
|
||||
&& window.giaWorkspaceState[pid].forceSend);
|
||||
if (forceInput) {
|
||||
forceInput.value = force ? "1" : "0";
|
||||
}
|
||||
|
||||
@@ -10,6 +10,57 @@
|
||||
<p class="is-size-7 has-text-weight-semibold">Selected Person</p>
|
||||
<h3 class="title is-5" style="margin-bottom: 0.25rem;">{{ person.name }}</h3>
|
||||
<p class="is-size-7">Showing last {{ limit }} messages.</p>
|
||||
<div class="tags" style="margin-top: 0.35rem;">
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='platform' %}">Platform {{ workspace_conversation.platform_type|title }}</a>
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='thread' %}">Thread {{ workspace_conversation.platform_thread_id|default:"-" }}</a>
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='workspace_created' %}">Workspace Created {{ workspace_conversation.created_at|default:"-" }}</a>
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='stability_state' %}">Stability {{ workspace_conversation.stability_state|title }}</a>
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='stability_score' %}">Stability Score {{ workspace_conversation.stability_score|default:"-" }}</a>
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='stability_confidence' %}">Confidence {{ workspace_conversation.stability_confidence }}</a>
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='sample_messages' %}">Sample Msg {{ workspace_conversation.stability_sample_messages }}</a>
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='sample_days' %}">Sample Days {{ workspace_conversation.stability_sample_days }}</a>
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='stability_computed' %}">Stability Computed {{ workspace_conversation.stability_last_computed_at|default:"-" }}</a>
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='commitment_inbound' %}">Commit In {{ workspace_conversation.commitment_inbound_score|default:"-" }}</a>
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='commitment_outbound' %}">Commit Out {{ workspace_conversation.commitment_outbound_score|default:"-" }}</a>
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='commitment_confidence' %}">Commit Confidence {{ workspace_conversation.commitment_confidence }}</a>
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='commitment_computed' %}">Commitment Computed {{ workspace_conversation.commitment_last_computed_at|default:"-" }}</a>
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='last_event' %}">Last Event {{ workspace_conversation.last_event_ts|default:"-" }}</a>
|
||||
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='last_ai_run' %}">Last AI Run {{ workspace_conversation.last_ai_run_at|default:"-" }}</a>
|
||||
</div>
|
||||
<div class="buttons are-small" style="margin-top: 0.35rem; margin-bottom: 0;">
|
||||
<a class="button is-light" href="{% url 'ai_workspace_insight_graphs' type='page' person_id=person.id %}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
|
||||
<span>Insight Graphs</span>
|
||||
</a>
|
||||
<a class="button is-light" href="{% url 'ai_workspace_insight_help' type='page' person_id=person.id %}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-circle-question"></i></span>
|
||||
<span>Scoring Help</span>
|
||||
</a>
|
||||
</div>
|
||||
{% with participants=workspace_conversation.participants.all %}
|
||||
{% if participants %}
|
||||
<p class="is-size-7" style="margin-top: 0.35rem; margin-bottom: 0;">
|
||||
Participants:
|
||||
{% for participant in participants %}
|
||||
{% if not forloop.first %}, {% endif %}
|
||||
{{ participant.name }}
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% if workspace_conversation.participant_feedback %}
|
||||
<p class="is-size-7" style="margin-top: 0.35rem; margin-bottom: 0;">
|
||||
Participant Feedback: {{ workspace_conversation.participant_feedback }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if compose_page_url %}
|
||||
<div class="buttons are-small" style="margin-top: 0.45rem; margin-bottom: 0;">
|
||||
<a class="button is-light" href="{{ compose_page_url }}">
|
||||
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
||||
<span>Manual Text Mode</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="notification is-{{ send_state.level }} is-light" style="padding: 0.5rem 0.75rem;">
|
||||
@@ -459,32 +510,32 @@
|
||||
fetch(url, { method: "GET" })
|
||||
.then(function(resp) { return resp.text(); })
|
||||
.then(function(html) {
|
||||
pane.innerHTML = html;
|
||||
pane.dataset.loaded = "1";
|
||||
executeInlineScripts(pane);
|
||||
pane.classList.remove("ai-animate-in");
|
||||
void pane.offsetWidth;
|
||||
pane.classList.add("ai-animate-in");
|
||||
if (cacheAllowed) {
|
||||
window.giaWorkspaceCache[key] = {
|
||||
html: html,
|
||||
ts: Date.now(),
|
||||
};
|
||||
persistCache();
|
||||
setCachedIndicator(true, window.giaWorkspaceCache[key].ts);
|
||||
} else {
|
||||
setCachedIndicator(false, null);
|
||||
}
|
||||
if (window.htmx) {
|
||||
window.htmx.process(pane);
|
||||
}
|
||||
if (operation === "draft_reply" && typeof window.giaWorkspaceUseDraft === "function") {
|
||||
window.giaWorkspaceUseDraft(personId, operation, 0);
|
||||
}
|
||||
})
|
||||
pane.innerHTML = html;
|
||||
pane.dataset.loaded = "1";
|
||||
executeInlineScripts(pane);
|
||||
pane.classList.remove("ai-animate-in");
|
||||
void pane.offsetWidth;
|
||||
pane.classList.add("ai-animate-in");
|
||||
if (cacheAllowed) {
|
||||
window.giaWorkspaceCache[key] = {
|
||||
html: html,
|
||||
ts: Date.now(),
|
||||
};
|
||||
persistCache();
|
||||
setCachedIndicator(true, window.giaWorkspaceCache[key].ts);
|
||||
} else {
|
||||
setCachedIndicator(false, null);
|
||||
}
|
||||
if (window.htmx) {
|
||||
window.htmx.process(pane);
|
||||
}
|
||||
if (operation === "draft_reply" && typeof window.giaWorkspaceUseDraft === "function") {
|
||||
window.giaWorkspaceUseDraft(personId, operation, 0);
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
pane.innerHTML = '<div class="notification is-danger is-light ai-animate-in">Failed to load AI response.</div>';
|
||||
});
|
||||
pane.innerHTML = '<div class="notification is-danger is-light ai-animate-in">Failed to load AI response.</div>';
|
||||
});
|
||||
};
|
||||
|
||||
window.giaWorkspaceRefresh = function(pid) {
|
||||
@@ -576,15 +627,15 @@
|
||||
})
|
||||
.then(function(resp) { return resp.text(); })
|
||||
.then(function(html) {
|
||||
if (statusHost) {
|
||||
statusHost.innerHTML = html;
|
||||
}
|
||||
})
|
||||
if (statusHost) {
|
||||
statusHost.innerHTML = html;
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
if (statusHost) {
|
||||
statusHost.innerHTML = '<div class="notification is-danger is-light" style="padding: 0.45rem 0.6rem;">Failed to queue draft.</div>';
|
||||
}
|
||||
});
|
||||
if (statusHost) {
|
||||
statusHost.innerHTML = '<div class="notification is-danger is-light" style="padding: 0.45rem 0.6rem;">Failed to queue draft.</div>';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof window.giaMitigationShowTab !== "function") {
|
||||
|
||||
341
core/templates/partials/compose-panel.html
Normal file
341
core/templates/partials/compose-panel.html
Normal file
@@ -0,0 +1,341 @@
|
||||
<div id="{{ panel_id }}" class="compose-shell">
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; flex-wrap: wrap;">
|
||||
<div>
|
||||
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.2rem;">Manual Text Mode</p>
|
||||
<p class="is-size-6" style="margin-bottom: 0;">
|
||||
{% if person %}
|
||||
{{ person.name }}
|
||||
{% else %}
|
||||
{{ identifier }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="is-size-7 compose-meta-line" style="margin-bottom: 0;">
|
||||
{{ service|title }} · {{ identifier }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="buttons are-small" style="margin: 0;">
|
||||
<a class="button is-light is-rounded" href="{{ ai_workspace_url }}">
|
||||
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
||||
<span>AI Workspace</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="{{ panel_id }}-status" class="compose-status">
|
||||
{% include "partials/compose-send-status.html" %}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="{{ panel_id }}-thread"
|
||||
class="compose-thread"
|
||||
data-poll-url="{% url 'compose_thread' %}"
|
||||
data-service="{{ service }}"
|
||||
data-identifier="{{ identifier }}"
|
||||
data-person="{% if person %}{{ person.id }}{% endif %}"
|
||||
data-limit="{{ limit }}"
|
||||
data-last-ts="{{ last_ts }}">
|
||||
{% for msg in serialized_messages %}
|
||||
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}">
|
||||
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
|
||||
<p class="compose-body">{{ msg.text|default:"(no text)" }}</p>
|
||||
<p class="compose-msg-meta">
|
||||
{{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="compose-empty">No stored messages for this contact yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<form
|
||||
id="{{ panel_id }}-form"
|
||||
class="compose-form"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-post="{% url 'compose_send' %}"
|
||||
hx-target="#{{ panel_id }}-status"
|
||||
hx-swap="innerHTML">
|
||||
<input type="hidden" name="service" value="{{ service }}">
|
||||
<input type="hidden" name="identifier" value="{{ identifier }}">
|
||||
<input type="hidden" name="render_mode" value="{{ render_mode }}">
|
||||
<input type="hidden" name="limit" value="{{ limit }}">
|
||||
{% if person %}
|
||||
<input type="hidden" name="person" value="{{ person.id }}">
|
||||
{% endif %}
|
||||
<div class="compose-composer-capsule">
|
||||
<textarea
|
||||
id="{{ panel_id }}-textarea"
|
||||
class="compose-textarea"
|
||||
name="text"
|
||||
rows="1"
|
||||
placeholder="Type a message. Enter to send, Shift+Enter for newline."></textarea>
|
||||
<button class="button is-link is-light compose-send-btn" type="submit">
|
||||
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
||||
<span>Send</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#{{ panel_id }}.compose-shell {
|
||||
border: 1px solid rgba(0, 0, 0, 0.16);
|
||||
border-radius: 8px;
|
||||
box-shadow: none;
|
||||
padding: 0.7rem;
|
||||
background: #fff;
|
||||
}
|
||||
#{{ panel_id }} .compose-thread {
|
||||
margin-top: 0.55rem;
|
||||
margin-bottom: 0.55rem;
|
||||
min-height: 16rem;
|
||||
max-height: 62vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem;
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.7), rgba(255, 255, 255, 0.98));
|
||||
}
|
||||
#{{ panel_id }} .compose-row {
|
||||
display: flex;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-row.is-in {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
#{{ panel_id }} .compose-row.is-out {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
#{{ panel_id }} .compose-bubble {
|
||||
max-width: min(85%, 46rem);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.13);
|
||||
padding: 0.52rem 0.62rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
#{{ panel_id }} .compose-bubble.is-in {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
#{{ panel_id }} .compose-bubble.is-out {
|
||||
background: #eef6ff;
|
||||
}
|
||||
#{{ panel_id }} .compose-body {
|
||||
margin: 0 0 0.2rem 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
#{{ panel_id }} .compose-msg-meta,
|
||||
#{{ panel_id }} .compose-meta-line {
|
||||
color: #616161;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-msg-meta {
|
||||
margin: 0;
|
||||
}
|
||||
#{{ panel_id }} .compose-empty {
|
||||
margin: 0;
|
||||
color: #6f6f6f;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-composer-capsule {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.16);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-textarea {
|
||||
flex: 1 1 auto;
|
||||
min-height: 2.45rem;
|
||||
max-height: 8rem;
|
||||
resize: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
line-height: 1.35;
|
||||
font-size: 0.98rem;
|
||||
padding: 0.45rem 0.5rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-send-btn {
|
||||
height: 2.45rem;
|
||||
border-radius: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
#{{ panel_id }} .compose-status {
|
||||
margin-top: 0.55rem;
|
||||
min-height: 1.1rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
#{{ panel_id }} .compose-thread {
|
||||
max-height: 52vh;
|
||||
}
|
||||
#{{ panel_id }} .compose-send-btn span:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const panelId = "{{ panel_id }}";
|
||||
const panel = document.getElementById(panelId);
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
const thread = document.getElementById(panelId + "-thread");
|
||||
const form = document.getElementById(panelId + "-form");
|
||||
const textarea = document.getElementById(panelId + "-textarea");
|
||||
if (!thread || !form || !textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.giaComposePanels = window.giaComposePanels || {};
|
||||
const previousState = window.giaComposePanels[panelId];
|
||||
if (previousState && previousState.timer) {
|
||||
clearInterval(previousState.timer);
|
||||
}
|
||||
if (previousState && previousState.eventHandler) {
|
||||
document.body.removeEventListener("composeMessageSent", previousState.eventHandler);
|
||||
}
|
||||
const panelState = { timer: null, polling: false };
|
||||
window.giaComposePanels[panelId] = panelState;
|
||||
|
||||
const toInt = function (value) {
|
||||
const parsed = parseInt(value || "0", 10);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
let lastTs = toInt(thread.dataset.lastTs);
|
||||
|
||||
const autosize = function () {
|
||||
textarea.style.height = "auto";
|
||||
const targetHeight = Math.min(Math.max(textarea.scrollHeight, 40), 128);
|
||||
textarea.style.height = targetHeight + "px";
|
||||
};
|
||||
textarea.addEventListener("input", autosize);
|
||||
autosize();
|
||||
|
||||
const nearBottom = function () {
|
||||
return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 100;
|
||||
};
|
||||
|
||||
const scrollToBottom = function (force) {
|
||||
if (force || nearBottom()) {
|
||||
thread.scrollTop = thread.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const appendBubble = function (msg) {
|
||||
const row = document.createElement("div");
|
||||
const outgoing = !!msg.outgoing;
|
||||
row.className = "compose-row " + (outgoing ? "is-out" : "is-in");
|
||||
row.dataset.ts = String(msg.ts || 0);
|
||||
|
||||
const bubble = document.createElement("article");
|
||||
bubble.className = "compose-bubble " + (outgoing ? "is-out" : "is-in");
|
||||
|
||||
const body = document.createElement("p");
|
||||
body.className = "compose-body";
|
||||
body.textContent = String(msg.text || "(no text)");
|
||||
bubble.appendChild(body);
|
||||
|
||||
const meta = document.createElement("p");
|
||||
meta.className = "compose-msg-meta";
|
||||
let metaText = String(msg.display_ts || msg.ts || "");
|
||||
if (msg.author) {
|
||||
metaText += " · " + String(msg.author);
|
||||
}
|
||||
meta.textContent = metaText;
|
||||
bubble.appendChild(meta);
|
||||
|
||||
row.appendChild(bubble);
|
||||
const empty = thread.querySelector(".compose-empty");
|
||||
if (empty) {
|
||||
empty.remove();
|
||||
}
|
||||
thread.appendChild(row);
|
||||
};
|
||||
|
||||
const poll = async function (forceScroll) {
|
||||
if (panelState.polling) {
|
||||
return;
|
||||
}
|
||||
panelState.polling = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("service", thread.dataset.service || "");
|
||||
params.set("identifier", thread.dataset.identifier || "");
|
||||
if (thread.dataset.person) {
|
||||
params.set("person", thread.dataset.person);
|
||||
}
|
||||
params.set("limit", thread.dataset.limit || "60");
|
||||
params.set("after_ts", String(lastTs));
|
||||
const url = thread.dataset.pollUrl + "?" + params.toString();
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const payload = await response.json();
|
||||
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
||||
const shouldStick = nearBottom() || forceScroll;
|
||||
messages.forEach(function (msg) {
|
||||
appendBubble(msg);
|
||||
lastTs = Math.max(lastTs, toInt(msg.ts));
|
||||
});
|
||||
if (payload.last_ts !== undefined && payload.last_ts !== null) {
|
||||
lastTs = Math.max(lastTs, toInt(payload.last_ts));
|
||||
}
|
||||
thread.dataset.lastTs = String(lastTs);
|
||||
if (messages.length > 0) {
|
||||
scrollToBottom(shouldStick);
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug("compose poll error", err);
|
||||
} finally {
|
||||
panelState.polling = false;
|
||||
}
|
||||
};
|
||||
|
||||
textarea.addEventListener("keydown", function (event) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener("htmx:afterRequest", function (event) {
|
||||
if (event.detail && event.detail.successful) {
|
||||
textarea.value = "";
|
||||
autosize();
|
||||
poll(true);
|
||||
textarea.focus();
|
||||
}
|
||||
});
|
||||
|
||||
panelState.eventHandler = function () {
|
||||
poll(true);
|
||||
};
|
||||
document.body.addEventListener("composeMessageSent", panelState.eventHandler);
|
||||
|
||||
scrollToBottom(true);
|
||||
panelState.timer = setInterval(function () {
|
||||
if (!document.getElementById(panelId)) {
|
||||
clearInterval(panelState.timer);
|
||||
document.body.removeEventListener(
|
||||
"composeMessageSent",
|
||||
panelState.eventHandler
|
||||
);
|
||||
delete window.giaComposePanels[panelId];
|
||||
return;
|
||||
}
|
||||
poll(false);
|
||||
}, 1800);
|
||||
})();
|
||||
</script>
|
||||
5
core/templates/partials/compose-send-status.html
Normal file
5
core/templates/partials/compose-send-status.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{% if notice_message %}
|
||||
<article class="notification is-{{ notice_level|default:'info' }} is-light" style="padding: 0.45rem 0.65rem; margin: 0;">
|
||||
{{ notice_message }}
|
||||
</article>
|
||||
{% endif %}
|
||||
@@ -17,6 +17,10 @@
|
||||
<th>sender</th>
|
||||
<th>text</th>
|
||||
<th>author</th>
|
||||
<th>delivered ts</th>
|
||||
<th>read ts</th>
|
||||
<th>read service</th>
|
||||
<th>read by</th>
|
||||
<th>actions</th>
|
||||
</thead>
|
||||
{% for item in object_list %}
|
||||
@@ -43,6 +47,10 @@
|
||||
</td>
|
||||
<td>{{ item.text }}</td>
|
||||
<td>{{ item.custom_author }}</td>
|
||||
<td>{{ item.delivered_ts }}</td>
|
||||
<td>{{ item.read_ts }}</td>
|
||||
<td>{{ item.read_source_service }}</td>
|
||||
<td>{{ item.read_by_identifier }}</td>
|
||||
<td>
|
||||
<div class="buttons">
|
||||
<button
|
||||
@@ -78,4 +86,4 @@
|
||||
{% endfor %}
|
||||
|
||||
</table>
|
||||
{% endcache %}
|
||||
{% endcache %}
|
||||
|
||||
13
core/templates/partials/nav-contacts-dropdown.html
Normal file
13
core/templates/partials/nav-contacts-dropdown.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% if items %}
|
||||
{% for item in items %}
|
||||
<a class="navbar-item" href="{{ item.compose_url }}">
|
||||
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
||||
<span style="margin-left: 0.35rem;">
|
||||
{{ item.person_name }} · {{ item.service|title }}
|
||||
</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<a class="navbar-item is-disabled">No contacts found.</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<th>id</th>
|
||||
<th>identifier</th>
|
||||
<th>last interaction</th>
|
||||
<th>summary</th>
|
||||
<th>actions</th>
|
||||
</thead>
|
||||
{% for item in object_list %}
|
||||
@@ -29,6 +30,7 @@
|
||||
</td>
|
||||
<td>{{ item.identifier }}</td>
|
||||
<td>{{ item.last_interaction }}</td>
|
||||
<td>{{ item.summary|default:"" }}</td>
|
||||
<td>
|
||||
<div class="buttons">
|
||||
<button
|
||||
@@ -73,4 +75,4 @@
|
||||
{% endfor %}
|
||||
|
||||
</table>
|
||||
{% endcache %}
|
||||
{% endcache %}
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
|
||||
<img src="data:image/png;base64, {{ object }}" alt="Signal QR code" />
|
||||
<img src="data:image/png;base64, {{ object }}" alt="Service QR code" />
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
{% load cache %}
|
||||
{% include 'mixins/partials/notify.html' %}
|
||||
{% cache 600 objects_signal_accounts request.user.id object_list type %}
|
||||
{% cache 600 objects_signal_accounts request.user.id object_list type service %}
|
||||
{% if service_warning %}
|
||||
<article class="notification is-warning is-light" style="margin-bottom: 0.55rem;">
|
||||
{{ service_warning }}
|
||||
</article>
|
||||
{% endif %}
|
||||
<table
|
||||
class="table is-fullwidth is-hoverable"
|
||||
hx-target="#{{ context_object_name }}-table"
|
||||
@@ -9,7 +14,7 @@
|
||||
hx-trigger="{{ context_object_name_singular }}Event from:body"
|
||||
hx-get="{{ list_url }}">
|
||||
<thead>
|
||||
<th>number</th>
|
||||
<th>{{ service_label|default:"Service" }} account</th>
|
||||
<th>actions</th>
|
||||
</thead>
|
||||
{% for item in object_list %}
|
||||
@@ -31,52 +36,54 @@
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{% if type == 'page' %}
|
||||
<a href="{% url 'signal_contacts' type=type pk=item %}"><button
|
||||
class="button">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
{% if show_contact_actions %}
|
||||
{% if type == 'page' %}
|
||||
<a href="{% url 'signal_contacts' type=type pk=item %}"><button
|
||||
class="button">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</a>
|
||||
<a href="{% url 'signal_chats' type=type pk=item %}"><button
|
||||
class="button">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-envelope"></i>
|
||||
</button>
|
||||
</a>
|
||||
<a href="{% url 'signal_chats' type=type pk=item %}"><button
|
||||
class="button">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-envelope"></i>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</a>
|
||||
{% else %}
|
||||
<button
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{% url 'signal_contacts' type=type pk=item %}"
|
||||
hx-trigger="click"
|
||||
hx-target="#{{ type }}s-here"
|
||||
hx-swap="innerHTML"
|
||||
class="button">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
</button>
|
||||
</a>
|
||||
{% else %}
|
||||
<button
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{% url 'signal_contacts' type=type pk=item %}"
|
||||
hx-trigger="click"
|
||||
hx-target="#{{ type }}s-here"
|
||||
hx-swap="innerHTML"
|
||||
class="button">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{% url 'signal_chats' type=type pk=item %}"
|
||||
hx-trigger="click"
|
||||
hx-target="#{{ type }}s-here"
|
||||
hx-swap="innerHTML"
|
||||
class="button">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-envelope"></i>
|
||||
</button>
|
||||
<button
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{% url 'signal_chats' type=type pk=item %}"
|
||||
hx-trigger="click"
|
||||
hx-target="#{{ type }}s-here"
|
||||
hx-swap="innerHTML"
|
||||
class="button">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-envelope"></i>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
@@ -86,19 +93,17 @@
|
||||
</table>
|
||||
<form
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-post="{% url 'signal_account_add' type=type %}"
|
||||
hx-target="#modals-here"
|
||||
hx-post="{% url account_add_url_name type=type %}"
|
||||
hx-target="#widgets-here"
|
||||
hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<div class="field has-addons">
|
||||
<div id="device" class="control is-expanded has-icons-left">
|
||||
<input
|
||||
hx-post="{% url 'signal_account_add' type=type %}"
|
||||
hx-target="#widgets-here"
|
||||
hx-swap="innerHTML"
|
||||
name="device"
|
||||
class="input"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Account name">
|
||||
<span class="icon is-small is-left">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
@@ -108,15 +113,13 @@
|
||||
<div class="field">
|
||||
<button
|
||||
id="search"
|
||||
type="submit"
|
||||
class="button is-fullwidth"
|
||||
hx-post="{% url 'signal_account_add' type=type %}"
|
||||
hx-trigger="click"
|
||||
hx-target="#widgets-here"
|
||||
hx-swap="innerHTML">
|
||||
>
|
||||
Add account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endcache %}
|
||||
{% endcache %}
|
||||
|
||||
@@ -13,22 +13,24 @@
|
||||
<th>uuid</th>
|
||||
<th>account</th>
|
||||
<th>name</th>
|
||||
<th>person</th>
|
||||
<th>actions</th>
|
||||
</thead>
|
||||
{% for item in object_list %}
|
||||
<tr>
|
||||
<td>{{ item.source_number }}</td>
|
||||
<td>{{ item.chat.source_number }}</td>
|
||||
<td>
|
||||
<a
|
||||
class="has-text-grey button nowrap-child"
|
||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.source_uuid }}');">
|
||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.chat.source_uuid }}');">
|
||||
<span class="icon" data-tooltip="Copy to clipboard">
|
||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ item.account }}</td>
|
||||
<td>{{ item.source_name }}</td>
|
||||
<td>{{ item.chat.account }}</td>
|
||||
<td>{{ item.chat.source_name }}</td>
|
||||
<td>{{ item.person_name|default:"-" }}</td>
|
||||
<td>
|
||||
<div class="buttons">
|
||||
<button
|
||||
@@ -37,7 +39,7 @@
|
||||
hx-trigger="click"
|
||||
hx-target="#modals-here"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Are you sure you wish to unlink {{ item }}?"
|
||||
hx-confirm="Are you sure you wish to unlink {{ item.chat }}?"
|
||||
class="button">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
@@ -46,51 +48,67 @@
|
||||
</span>
|
||||
</button>
|
||||
{% if type == 'page' %}
|
||||
<a href="{# url 'signal_contacts' type=type pk=item #}"><button
|
||||
class="button">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
{% if item.can_compose %}
|
||||
<a href="{{ item.compose_page_url }}"><button
|
||||
class="button"
|
||||
title="Manual text mode">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="{{ item.manual_icon_class }}"></i>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</a>
|
||||
<a href="{# url 'signal_chats' type=type pk=item #}"><button
|
||||
class="button">
|
||||
</button>
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="button" disabled title="No identifier available for manual send">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="{{ item.manual_icon_class }}"></i>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{{ item.ai_url }}"><button
|
||||
class="button"
|
||||
title="Open AI workspace">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-envelope"></i>
|
||||
<i class="fa-solid fa-brain-circuit"></i>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</a>
|
||||
{% else %}
|
||||
<button
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{# url 'signal_contacts' type=type pk=item #}"
|
||||
hx-trigger="click"
|
||||
hx-target="#{{ type }}s-here"
|
||||
hx-swap="innerHTML"
|
||||
class="button">
|
||||
{% if item.can_compose %}
|
||||
<button
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{{ item.compose_widget_url }}"
|
||||
hx-trigger="click"
|
||||
hx-target="#widgets-here"
|
||||
hx-swap="afterend"
|
||||
class="button">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="{{ item.manual_icon_class }}"></i>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{% else %}
|
||||
<button class="button" disabled title="No identifier available for manual send">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="{{ item.manual_icon_class }}"></i>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{{ item.ai_url }}"><button class="button">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
<i class="fa-solid fa-brain-circuit"></i>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{# url 'signal_chats' type=type pk=item #}"
|
||||
hx-trigger="click"
|
||||
hx-target="#{{ type }}s-here"
|
||||
hx-swap="innerHTML"
|
||||
class="button">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-envelope"></i>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</button></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
@@ -98,4 +116,4 @@
|
||||
{% endfor %}
|
||||
|
||||
</table>
|
||||
{% endcache %}
|
||||
{% endcache %}
|
||||
|
||||
@@ -2,10 +2,8 @@ import logging
|
||||
|
||||
# import stripe
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse_lazy
|
||||
from django.views import View
|
||||
from django.views.generic.edit import CreateView
|
||||
|
||||
|
||||
369
core/views/compose.py
Normal file
369
core/views/compose.py
Normal file
@@ -0,0 +1,369 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpResponseBadRequest, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone as dj_timezone
|
||||
from django.views import View
|
||||
|
||||
from core.clients import transport
|
||||
from core.models import ChatSession, Message, Person, PersonIdentifier
|
||||
|
||||
|
||||
def _default_service(service: str | None) -> str:
|
||||
value = str(service or "").strip().lower()
|
||||
if value in {"signal", "whatsapp", "instagram", "xmpp"}:
|
||||
return value
|
||||
return "signal"
|
||||
|
||||
|
||||
def _safe_limit(raw) -> int:
|
||||
try:
|
||||
value = int(raw or 40)
|
||||
except (TypeError, ValueError):
|
||||
value = 40
|
||||
return max(10, min(value, 200))
|
||||
|
||||
|
||||
def _safe_after_ts(raw) -> int:
|
||||
try:
|
||||
value = int(raw or 0)
|
||||
except (TypeError, ValueError):
|
||||
value = 0
|
||||
return max(0, value)
|
||||
|
||||
|
||||
def _format_ts_label(ts_value: int) -> str:
|
||||
try:
|
||||
as_dt = datetime.fromtimestamp(int(ts_value) / 1000, tz=dt_timezone.utc)
|
||||
return dj_timezone.localtime(as_dt).strftime("%H:%M")
|
||||
except Exception:
|
||||
return str(ts_value or "")
|
||||
|
||||
|
||||
def _is_outgoing(msg: Message) -> bool:
|
||||
return str(msg.custom_author or "").upper() in {"USER", "BOT"}
|
||||
|
||||
|
||||
def _serialize_message(msg: Message) -> dict:
|
||||
author = str(msg.custom_author or "").strip()
|
||||
return {
|
||||
"id": str(msg.id),
|
||||
"ts": int(msg.ts or 0),
|
||||
"display_ts": _format_ts_label(int(msg.ts or 0)),
|
||||
"text": str(msg.text or ""),
|
||||
"author": author,
|
||||
"outgoing": _is_outgoing(msg),
|
||||
}
|
||||
|
||||
|
||||
def _context_base(user, service, identifier, person):
|
||||
person_identifier = None
|
||||
if person is not None:
|
||||
person_identifier = (
|
||||
PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
person=person,
|
||||
service=service,
|
||||
).first()
|
||||
or PersonIdentifier.objects.filter(user=user, person=person).first()
|
||||
)
|
||||
if person_identifier is None and identifier:
|
||||
person_identifier = PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
service=service,
|
||||
identifier=identifier,
|
||||
).first()
|
||||
|
||||
if person_identifier:
|
||||
service = person_identifier.service
|
||||
identifier = person_identifier.identifier
|
||||
person = person_identifier.person
|
||||
|
||||
return {
|
||||
"person_identifier": person_identifier,
|
||||
"service": service,
|
||||
"identifier": identifier,
|
||||
"person": person,
|
||||
}
|
||||
|
||||
|
||||
def _compose_urls(service, identifier, person_id):
|
||||
query = {"service": service, "identifier": identifier}
|
||||
if person_id:
|
||||
query["person"] = str(person_id)
|
||||
payload = urlencode(query)
|
||||
return {
|
||||
"page_url": f"{reverse('compose_page')}?{payload}",
|
||||
"widget_url": f"{reverse('compose_widget')}?{payload}",
|
||||
}
|
||||
|
||||
|
||||
def _load_messages(user, person_identifier, limit):
|
||||
if person_identifier is None:
|
||||
return {"session": None, "messages": []}
|
||||
|
||||
session, _ = ChatSession.objects.get_or_create(
|
||||
user=user,
|
||||
identifier=person_identifier,
|
||||
)
|
||||
messages = list(
|
||||
Message.objects.filter(user=user, session=session)
|
||||
.select_related("session", "session__identifier", "session__identifier__person")
|
||||
.order_by("-ts")[:limit]
|
||||
)
|
||||
messages.reverse()
|
||||
return {"session": session, "messages": messages}
|
||||
|
||||
|
||||
def _panel_context(
|
||||
request,
|
||||
service: str,
|
||||
identifier: str,
|
||||
person: Person | None,
|
||||
render_mode: str,
|
||||
notice: str = "",
|
||||
level: str = "success",
|
||||
):
|
||||
base = _context_base(request.user, service, identifier, person)
|
||||
limit = _safe_limit(request.GET.get("limit") or request.POST.get("limit"))
|
||||
session_bundle = _load_messages(request.user, base["person_identifier"], limit)
|
||||
last_ts = 0
|
||||
if session_bundle["messages"]:
|
||||
last_ts = int(session_bundle["messages"][-1].ts or 0)
|
||||
urls = _compose_urls(
|
||||
base["service"],
|
||||
base["identifier"],
|
||||
base["person"].id if base["person"] else None,
|
||||
)
|
||||
|
||||
unique_raw = f"{base['service']}|{base['identifier']}|{request.user.id}"
|
||||
unique = hashlib.sha1(unique_raw.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
return {
|
||||
"service": base["service"],
|
||||
"identifier": base["identifier"],
|
||||
"person": base["person"],
|
||||
"person_identifier": base["person_identifier"],
|
||||
"session": session_bundle["session"],
|
||||
"messages": session_bundle["messages"],
|
||||
"serialized_messages": [
|
||||
_serialize_message(msg) for msg in session_bundle["messages"]
|
||||
],
|
||||
"last_ts": last_ts,
|
||||
"limit": limit,
|
||||
"notice_message": notice,
|
||||
"notice_level": level,
|
||||
"render_mode": render_mode,
|
||||
"compose_page_url": urls["page_url"],
|
||||
"compose_widget_url": urls["widget_url"],
|
||||
"ai_workspace_url": (
|
||||
f"{reverse('ai_workspace')}?person={base['person'].id}"
|
||||
if base["person"]
|
||||
else reverse("ai_workspace")
|
||||
),
|
||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||
"panel_id": f"compose-panel-{unique}",
|
||||
}
|
||||
|
||||
|
||||
class ComposeContactsDropdown(LoginRequiredMixin, View):
|
||||
def get(self, request):
|
||||
rows = list(
|
||||
PersonIdentifier.objects.filter(user=request.user)
|
||||
.select_related("person")
|
||||
.order_by("person__name", "service", "identifier")
|
||||
)
|
||||
items = []
|
||||
for row in rows:
|
||||
urls = _compose_urls(row.service, row.identifier, row.person_id)
|
||||
items.append(
|
||||
{
|
||||
"person_name": row.person.name,
|
||||
"service": row.service,
|
||||
"identifier": row.identifier,
|
||||
"compose_url": urls["page_url"],
|
||||
}
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"partials/nav-contacts-dropdown.html",
|
||||
{
|
||||
"items": items,
|
||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ComposePage(LoginRequiredMixin, View):
|
||||
template_name = "pages/compose.html"
|
||||
|
||||
def get(self, request):
|
||||
service = _default_service(request.GET.get("service"))
|
||||
identifier = str(request.GET.get("identifier") or "").strip()
|
||||
person = None
|
||||
person_id = request.GET.get("person")
|
||||
if person_id:
|
||||
person = get_object_or_404(Person, id=person_id, user=request.user)
|
||||
if not identifier and person is None:
|
||||
return HttpResponseBadRequest("Missing contact identifier.")
|
||||
|
||||
context = _panel_context(
|
||||
request=request,
|
||||
service=service,
|
||||
identifier=identifier,
|
||||
person=person,
|
||||
render_mode="page",
|
||||
)
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
|
||||
class ComposeWidget(LoginRequiredMixin, View):
|
||||
def get(self, request):
|
||||
service = _default_service(request.GET.get("service"))
|
||||
identifier = str(request.GET.get("identifier") or "").strip()
|
||||
person = None
|
||||
person_id = request.GET.get("person")
|
||||
if person_id:
|
||||
person = get_object_or_404(Person, id=person_id, user=request.user)
|
||||
if not identifier and person is None:
|
||||
return HttpResponseBadRequest("Missing contact identifier.")
|
||||
|
||||
panel_context = _panel_context(
|
||||
request=request,
|
||||
service=service,
|
||||
identifier=identifier,
|
||||
person=person,
|
||||
render_mode="widget",
|
||||
)
|
||||
title_name = (
|
||||
panel_context["person"].name
|
||||
if panel_context["person"] is not None
|
||||
else panel_context["identifier"]
|
||||
)
|
||||
context = {
|
||||
"title": f"Manual Chat: {title_name}",
|
||||
"unique": f"compose-{panel_context['panel_id']}",
|
||||
"window_content": "partials/compose-panel.html",
|
||||
"widget_options": 'gs-w="6" gs-h="12" gs-x="0" gs-y="0" gs-min-w="4"',
|
||||
**panel_context,
|
||||
}
|
||||
return render(request, "mixins/wm/widget.html", context)
|
||||
|
||||
|
||||
class ComposeThread(LoginRequiredMixin, View):
|
||||
def get(self, request):
|
||||
service = _default_service(request.GET.get("service"))
|
||||
identifier = str(request.GET.get("identifier") or "").strip()
|
||||
person = None
|
||||
person_id = request.GET.get("person")
|
||||
if person_id:
|
||||
person = get_object_or_404(Person, id=person_id, user=request.user)
|
||||
if not identifier and person is None:
|
||||
return HttpResponseBadRequest("Missing contact identifier.")
|
||||
|
||||
limit = _safe_limit(request.GET.get("limit") or 60)
|
||||
after_ts = _safe_after_ts(request.GET.get("after_ts"))
|
||||
base = _context_base(request.user, service, identifier, person)
|
||||
latest_ts = after_ts
|
||||
messages = []
|
||||
if base["person_identifier"] is not None:
|
||||
session, _ = ChatSession.objects.get_or_create(
|
||||
user=request.user,
|
||||
identifier=base["person_identifier"],
|
||||
)
|
||||
queryset = Message.objects.filter(user=request.user, session=session)
|
||||
if after_ts > 0:
|
||||
queryset = queryset.filter(ts__gt=after_ts)
|
||||
messages = list(
|
||||
queryset.select_related(
|
||||
"session",
|
||||
"session__identifier",
|
||||
"session__identifier__person",
|
||||
)
|
||||
.order_by("ts")[:limit]
|
||||
)
|
||||
newest = (
|
||||
Message.objects.filter(user=request.user, session=session)
|
||||
.order_by("-ts")
|
||||
.values_list("ts", flat=True)
|
||||
.first()
|
||||
)
|
||||
if newest:
|
||||
latest_ts = max(latest_ts, int(newest))
|
||||
payload = {
|
||||
"messages": [_serialize_message(msg) for msg in messages],
|
||||
"last_ts": latest_ts,
|
||||
}
|
||||
return JsonResponse(payload)
|
||||
|
||||
|
||||
class ComposeSend(LoginRequiredMixin, View):
|
||||
def post(self, request):
|
||||
service = _default_service(request.POST.get("service"))
|
||||
identifier = str(request.POST.get("identifier") or "").strip()
|
||||
person = None
|
||||
person_id = request.POST.get("person")
|
||||
if person_id:
|
||||
person = get_object_or_404(Person, id=person_id, user=request.user)
|
||||
render_mode = str(request.POST.get("render_mode") or "page").strip().lower()
|
||||
if render_mode not in {"page", "widget"}:
|
||||
render_mode = "page"
|
||||
|
||||
if not identifier and person is None:
|
||||
return HttpResponseBadRequest("Missing contact identifier.")
|
||||
|
||||
text = str(request.POST.get("text") or "").strip()
|
||||
if not text:
|
||||
return render(
|
||||
request,
|
||||
"partials/compose-send-status.html",
|
||||
{"notice_message": "Message is empty.", "notice_level": "danger"},
|
||||
)
|
||||
|
||||
base = _context_base(request.user, service, identifier, person)
|
||||
ts = async_to_sync(transport.send_message_raw)(
|
||||
base["service"],
|
||||
base["identifier"],
|
||||
text=text,
|
||||
attachments=[],
|
||||
)
|
||||
if not ts:
|
||||
return render(
|
||||
request,
|
||||
"partials/compose-send-status.html",
|
||||
{
|
||||
"notice_message": "Send failed. Check service account state.",
|
||||
"notice_level": "danger",
|
||||
},
|
||||
)
|
||||
|
||||
if base["person_identifier"] is not None:
|
||||
session, _ = ChatSession.objects.get_or_create(
|
||||
user=request.user,
|
||||
identifier=base["person_identifier"],
|
||||
)
|
||||
Message.objects.create(
|
||||
user=request.user,
|
||||
session=session,
|
||||
sender_uuid="",
|
||||
text=text,
|
||||
ts=int(ts) if str(ts).isdigit() else int(time.time() * 1000),
|
||||
delivered_ts=int(ts) if str(ts).isdigit() else None,
|
||||
custom_author="USER",
|
||||
)
|
||||
|
||||
response = render(
|
||||
request,
|
||||
"partials/compose-send-status.html",
|
||||
{"notice_message": "Sent.", "notice_level": "success"},
|
||||
)
|
||||
response["HX-Trigger"] = "composeMessageSent"
|
||||
return response
|
||||
29
core/views/instagram.py
Normal file
29
core/views/instagram.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from core.clients import transport
|
||||
from core.views.signal import Signal, SignalAccountAdd, SignalAccounts
|
||||
|
||||
|
||||
class Instagram(Signal):
|
||||
service = "instagram"
|
||||
page_title = "Instagram"
|
||||
accounts_url_name = "instagram_accounts"
|
||||
|
||||
|
||||
class InstagramAccounts(SignalAccounts):
|
||||
service = "instagram"
|
||||
context_object_name_singular = "Instagram Account"
|
||||
context_object_name = "Instagram Accounts"
|
||||
list_url_name = "instagram_accounts"
|
||||
|
||||
def get_queryset(self, **kwargs):
|
||||
self.extra_context = self._service_context(
|
||||
service="instagram",
|
||||
label="Instagram",
|
||||
add_url_name="instagram_account_add",
|
||||
show_contact_actions=False,
|
||||
)
|
||||
return self._normalize_accounts(transport.list_accounts("instagram"))
|
||||
|
||||
|
||||
class InstagramAccountAdd(SignalAccountAdd):
|
||||
service = "instagram"
|
||||
detail_url_name = "instagram_account_add"
|
||||
@@ -2,11 +2,11 @@ from asgiref.sync import async_to_sync
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponse
|
||||
from django.utils import timezone as dj_timezone
|
||||
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
|
||||
from rest_framework import status
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from core.clients import signalapi
|
||||
from core.forms import QueueForm
|
||||
from core.models import Message, QueuedMessage
|
||||
from core.util import logs
|
||||
@@ -28,22 +28,18 @@ class AcceptMessageAPI(LoginRequiredMixin, APIView):
|
||||
except QueuedMessage.DoesNotExist:
|
||||
return HttpResponse(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if queued.session.identifier.service != "signal":
|
||||
log.warning(
|
||||
"Queue accept failed: unsupported service '%s' for queued message %s",
|
||||
queued.session.identifier.service,
|
||||
queued.id,
|
||||
)
|
||||
return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
ts = async_to_sync(signalapi.send_message_raw)(
|
||||
queued.session.identifier.identifier,
|
||||
ts = async_to_sync(queued.session.identifier.send)(
|
||||
queued.text or "",
|
||||
[],
|
||||
)
|
||||
if not ts:
|
||||
log.error("Queue accept send failed for queued message %s", queued.id)
|
||||
return HttpResponse(status=status.HTTP_502_BAD_GATEWAY)
|
||||
sent_ts = (
|
||||
int(ts)
|
||||
if (ts is not None and not isinstance(ts, bool))
|
||||
else int(dj_timezone.now().timestamp() * 1000)
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
Message.objects.create(
|
||||
@@ -51,7 +47,9 @@ class AcceptMessageAPI(LoginRequiredMixin, APIView):
|
||||
session=queued.session,
|
||||
custom_author=queued.custom_author or "BOT",
|
||||
text=queued.text,
|
||||
ts=ts,
|
||||
ts=sent_ts,
|
||||
delivered_ts=sent_ts,
|
||||
read_source_service=queued.session.identifier.service,
|
||||
)
|
||||
queued.delete()
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import base64
|
||||
|
||||
import orjson
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
from mixins.views import ObjectList, ObjectRead
|
||||
|
||||
from core.models import Chat
|
||||
from core.clients import transport
|
||||
from core.models import Chat, PersonIdentifier
|
||||
from core.views.manage.permissions import SuperUserRequiredMixin
|
||||
|
||||
|
||||
@@ -18,13 +19,25 @@ class CustomObjectRead(ObjectRead):
|
||||
|
||||
class Signal(SuperUserRequiredMixin, View):
|
||||
template_name = "pages/signal.html"
|
||||
service = "signal"
|
||||
page_title = "Signal"
|
||||
accounts_url_name = "signal_accounts"
|
||||
|
||||
def get(self, request):
|
||||
return render(request, self.template_name)
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"service": self.service,
|
||||
"service_label": self.page_title,
|
||||
"accounts_url_name": self.accounts_url_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class SignalAccounts(SuperUserRequiredMixin, ObjectList):
|
||||
list_template = "partials/signal-accounts.html"
|
||||
service = "signal"
|
||||
|
||||
context_object_name_singular = "Signal Account"
|
||||
context_object_name = "Signal Accounts"
|
||||
@@ -32,13 +45,44 @@ class SignalAccounts(SuperUserRequiredMixin, ObjectList):
|
||||
list_url_name = "signal_accounts"
|
||||
list_url_args = ["type"]
|
||||
|
||||
def get_queryset(self, **kwargs):
|
||||
# url = signal:8080/v1/accounts
|
||||
url = f"http://signal:8080/v1/accounts"
|
||||
response = requests.get(url)
|
||||
accounts = orjson.loads(response.text)
|
||||
def _normalize_accounts(self, rows):
|
||||
out = []
|
||||
for item in rows or []:
|
||||
if isinstance(item, dict):
|
||||
value = (
|
||||
item.get("number")
|
||||
or item.get("id")
|
||||
or item.get("jid")
|
||||
or item.get("account")
|
||||
)
|
||||
if value:
|
||||
out.append(str(value))
|
||||
elif item:
|
||||
out.append(str(item))
|
||||
return out
|
||||
|
||||
return accounts
|
||||
def _service_context(self, service, label, add_url_name, show_contact_actions):
|
||||
return {
|
||||
"service": service,
|
||||
"service_label": label,
|
||||
"account_add_url_name": add_url_name,
|
||||
"show_contact_actions": show_contact_actions,
|
||||
"endpoint_base": str(
|
||||
getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")
|
||||
).rstrip("/")
|
||||
if service == "signal"
|
||||
else "",
|
||||
"service_warning": transport.get_service_warning(service),
|
||||
}
|
||||
|
||||
def get_queryset(self, **kwargs):
|
||||
self.extra_context = self._service_context(
|
||||
service="signal",
|
||||
label="Signal",
|
||||
add_url_name="signal_account_add",
|
||||
show_contact_actions=True,
|
||||
)
|
||||
return self._normalize_accounts(transport.list_accounts("signal"))
|
||||
|
||||
|
||||
class SignalContactsList(SuperUserRequiredMixin, ObjectList):
|
||||
@@ -55,13 +99,16 @@ class SignalContactsList(SuperUserRequiredMixin, ObjectList):
|
||||
# /v1/configuration/{number}/settings
|
||||
# /v1/identities/{number}
|
||||
# /v1/contacts/{number}
|
||||
# response = requests.get(f"http://signal:8080/v1/configuration/{self.kwargs['pk']}/settings")
|
||||
# response = requests.get(
|
||||
# f"http://signal:8080/v1/configuration/{self.kwargs['pk']}/settings"
|
||||
# )
|
||||
# config = orjson.loads(response.text)
|
||||
|
||||
response = requests.get(f"http://signal:8080/v1/identities/{self.kwargs['pk']}")
|
||||
base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/")
|
||||
response = requests.get(f"{base}/v1/identities/{self.kwargs['pk']}")
|
||||
identities = orjson.loads(response.text)
|
||||
|
||||
response = requests.get(f"http://signal:8080/v1/contacts/{self.kwargs['pk']}")
|
||||
response = requests.get(f"{base}/v1/contacts/{self.kwargs['pk']}")
|
||||
contacts = orjson.loads(response.text)
|
||||
|
||||
# add identities to contacts
|
||||
@@ -90,8 +137,59 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
pk = self.kwargs.get("pk", "")
|
||||
object_list = Chat.objects.filter(account=pk)
|
||||
return object_list
|
||||
chats = list(Chat.objects.filter(account=pk))
|
||||
rows = []
|
||||
for chat in chats:
|
||||
identifier_candidates = [
|
||||
str(chat.source_uuid or "").strip(),
|
||||
str(chat.source_number or "").strip(),
|
||||
]
|
||||
identifier_candidates = [value for value in identifier_candidates if value]
|
||||
person_identifier = None
|
||||
if identifier_candidates:
|
||||
person_identifier = (
|
||||
PersonIdentifier.objects.filter(
|
||||
user=self.request.user,
|
||||
service="signal",
|
||||
identifier__in=identifier_candidates,
|
||||
)
|
||||
.select_related("person")
|
||||
.first()
|
||||
)
|
||||
|
||||
identifier_value = (
|
||||
person_identifier.identifier if person_identifier else ""
|
||||
) or (chat.source_uuid or chat.source_number or "")
|
||||
service = "signal"
|
||||
compose_page_url = ""
|
||||
compose_widget_url = ""
|
||||
if identifier_value:
|
||||
query = f"service={service}&identifier={identifier_value}"
|
||||
if person_identifier:
|
||||
query += f"&person={person_identifier.person_id}"
|
||||
compose_page_url = f"{reverse('compose_page')}?{query}"
|
||||
compose_widget_url = f"{reverse('compose_widget')}?{query}"
|
||||
if person_identifier:
|
||||
ai_url = (
|
||||
f"{reverse('ai_workspace')}?person={person_identifier.person_id}"
|
||||
)
|
||||
else:
|
||||
ai_url = reverse("ai_workspace")
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"chat": chat,
|
||||
"compose_page_url": compose_page_url,
|
||||
"compose_widget_url": compose_widget_url,
|
||||
"ai_url": ai_url,
|
||||
"person_name": (
|
||||
person_identifier.person.name if person_identifier else ""
|
||||
),
|
||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||
"can_compose": bool(compose_page_url),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
class SignalMessagesList(SuperUserRequiredMixin, ObjectList):
|
||||
@@ -100,6 +198,7 @@ class SignalMessagesList(SuperUserRequiredMixin, ObjectList):
|
||||
|
||||
class SignalAccountAdd(SuperUserRequiredMixin, CustomObjectRead):
|
||||
detail_template = "partials/signal-account-add.html"
|
||||
service = "signal"
|
||||
|
||||
context_object_name_singular = "Add Account"
|
||||
context_object_name = "Add Account"
|
||||
@@ -112,9 +211,7 @@ class SignalAccountAdd(SuperUserRequiredMixin, CustomObjectRead):
|
||||
def get_object(self, **kwargs):
|
||||
form_args = self.request.POST.dict()
|
||||
device_name = form_args["device"]
|
||||
url = f"http://signal:8080/v1/qrcodelink?device_name={device_name}"
|
||||
response = requests.get(url)
|
||||
image_bytes = response.content
|
||||
base64_image = base64.b64encode(image_bytes).decode("utf-8")
|
||||
image_bytes = transport.get_link_qr(self.service, device_name)
|
||||
base64_image = transport.image_bytes_to_base64(image_bytes)
|
||||
|
||||
return base64_image
|
||||
|
||||
29
core/views/whatsapp.py
Normal file
29
core/views/whatsapp.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from core.clients import transport
|
||||
from core.views.signal import Signal, SignalAccountAdd, SignalAccounts
|
||||
|
||||
|
||||
class WhatsApp(Signal):
|
||||
service = "whatsapp"
|
||||
page_title = "WhatsApp"
|
||||
accounts_url_name = "whatsapp_accounts"
|
||||
|
||||
|
||||
class WhatsAppAccounts(SignalAccounts):
|
||||
service = "whatsapp"
|
||||
context_object_name_singular = "WhatsApp Account"
|
||||
context_object_name = "WhatsApp Accounts"
|
||||
list_url_name = "whatsapp_accounts"
|
||||
|
||||
def get_queryset(self, **kwargs):
|
||||
self.extra_context = self._service_context(
|
||||
service="whatsapp",
|
||||
label="WhatsApp",
|
||||
add_url_name="whatsapp_account_add",
|
||||
show_contact_actions=False,
|
||||
)
|
||||
return self._normalize_accounts(transport.list_accounts("whatsapp"))
|
||||
|
||||
|
||||
class WhatsAppAccountAdd(SignalAccountAdd):
|
||||
service = "whatsapp"
|
||||
detail_url_name = "whatsapp_account_add"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,8 @@ services:
|
||||
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
|
||||
OPERATION: "${OPERATION}"
|
||||
SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
|
||||
WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}"
|
||||
INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}"
|
||||
XMPP_ADDRESS: "${XMPP_ADDRESS}"
|
||||
XMPP_JID: "${XMPP_JID}"
|
||||
XMPP_PORT: "${XMPP_PORT}"
|
||||
@@ -103,6 +105,8 @@ services:
|
||||
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
|
||||
OPERATION: "${OPERATION}"
|
||||
SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
|
||||
WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}"
|
||||
INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}"
|
||||
XMPP_ADDRESS: "${XMPP_ADDRESS}"
|
||||
XMPP_JID: "${XMPP_JID}"
|
||||
XMPP_PORT: "${XMPP_PORT}"
|
||||
@@ -152,6 +156,8 @@ services:
|
||||
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
|
||||
OPERATION: "${OPERATION}"
|
||||
SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
|
||||
WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}"
|
||||
INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}"
|
||||
XMPP_ADDRESS: "${XMPP_ADDRESS}"
|
||||
XMPP_JID: "${XMPP_JID}"
|
||||
XMPP_PORT: "${XMPP_PORT}"
|
||||
@@ -200,6 +206,8 @@ services:
|
||||
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
|
||||
OPERATION: "${OPERATION}"
|
||||
SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
|
||||
WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}"
|
||||
INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}"
|
||||
XMPP_ADDRESS: "${XMPP_ADDRESS}"
|
||||
XMPP_JID: "${XMPP_JID}"
|
||||
XMPP_PORT: "${XMPP_PORT}"
|
||||
@@ -241,6 +249,8 @@ services:
|
||||
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
|
||||
OPERATION: "${OPERATION}"
|
||||
SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
|
||||
WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}"
|
||||
INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}"
|
||||
XMPP_ADDRESS: "${XMPP_ADDRESS}"
|
||||
XMPP_JID: "${XMPP_JID}"
|
||||
XMPP_PORT: "${XMPP_PORT}"
|
||||
|
||||
Reference in New Issue
Block a user