Improve chat experience and begin search implementation

This commit is contained in:
2026-02-15 17:32:26 +00:00
parent 6612274ab9
commit a94bbff655
21 changed files with 3081 additions and 179 deletions

279
README.md
View File

@@ -1,149 +1,198 @@
# ☠️ GIA Gather, Influence, Automate ☠️ # GIA
GIA isnt just a tool—its a **gateway**, a **ritualistic mechanism** designed to invoke, manipulate, and command the digital souls of the unsuspecting. Operating at the **intersection of psychological domination, deception, and control**, it bends the will of targets with **supernatural precision**. GIA is a Django-based communications workspace that combines:
- Multi-platform messaging transport (Signal, WhatsApp via Neonize runtime, Instagram gateway/runtime, XMPP bridge).
- AI-assisted drafting, summaries, pattern extraction, and mitigation planning.
- Queueing and approval workflows for controlled outbound messaging.
GIA whispers in the void, embedding its tendrils into minds, shaping realities, and orchestrating **obedience with unseen hands**. This document covers architecture, setup, runtime behavior, and development workflow.
💀 **Manipulate. Dominate. Obliterate.** 💀 ## Architecture
### High-level flow
1. Transport clients receive messages (Signal/WhatsApp/Instagram/XMPP).
2. UR (`manage.py ur`) normalizes events and routes them through unified handlers.
3. Message history is persisted in Django models (`Message`, `ChatSession`, identifiers).
4. UI pages (workspace + manual compose) read/write through Django views.
5. AI operations run through configured `AI` provider rows and store results in workspace models.
## 📌 Features ### Core components
- **Automated Psychological Influence:** Deploys manipulation techniques with precision. - `core/modules/router.py`:
- **AI-Driven Conversations:** Uses contextual AI to interact and persuade. - Unified Router entrypoint.
- **Targeted Persona Simulation:** Adapts responses based on psychological profiling. - Starts transport clients.
- **Multi-Agent Execution:** Processing, scheduling, and monitoring through modular services. - Cross-protocol typing/read events.
- **Covert Deployment:** Runs in a distributed architecture with signal interception. - `core/clients/`:
- `signal.py` + `signalapi.py`: Signal transport integration.
- `whatsapp.py`: Neonize-backed WhatsApp runtime client.
- `instagram.py`: Instagram runtime/gateway integration.
- `xmpp.py`: XMPP bridge client.
- `transport.py`: Unified send/typing/QR API used by views/models.
- `core/messaging/`:
- AI execution wrapper.
- Message history persistence and enrichment helpers.
- `core/views/`:
- `workspace.py`: AI workspace, insights, mitigation plans/artifacts.
- `compose.py`: Manual chat mode, AI drafts/summary/engage shortcuts.
- `signal.py`, `whatsapp.py`, `instagram.py`: Service setup/account pages.
## 🚀 Quickstart Guide ### Data model highlights
- `Person` + `PersonIdentifier`:
- Identity layer across services (`signal`, `whatsapp`, `instagram`, `xmpp`).
- `ChatSession` + `Message`:
- Conversation storage.
- Delivery/read metadata fields on `Message`.
- `WorkspaceConversation`:
- AI workspace context per person/thread.
- Stability + commitment scores and confidence.
- `WorkspaceMetricSnapshot`:
- Historical metric snapshots for trend charts.
- Pattern mitigation models:
- `PatternMitigationPlan`, `Rule`, `Game`, `Correction`, exports, and auto settings.
### 🔧 Setting Up the Environment ## Runtime services
GIA runs inside **Podman containers**. Ensure you have **Podman** and **Podman Compose** installed. Docker might work. Results may vary.
1. **Clone the repository** `docker-compose.yml` defines these primary services:
```shell - `app`: Django web process.
git clone https://github.com/your-repo/gia.git - `ur`: Unified Router runtime (transport clients).
cd gia - `scheduling`: Scheduled jobs.
- `migration`: Startup migrations.
- `collectstatic`: Static asset collection.
- `redis`: Cache/runtime signaling.
- `signal-cli-rest-api`: Signal backend.
## Installation
### Prerequisites
- Podman + Podman Compose (preferred in this repo), or Docker Compose.
- Linux environment with bind-mount access for configured paths.
### 1) Clone and configure
```bash
git clone <your-repo-url> GIA
cd GIA
cp stack.env.example stack.env
``` ```
2. Set up the environment variables Edit `stack.env` with at least:
```shell - `SECRET_KEY`
cp stack.env.example stack.env - `ALLOWED_HOSTS`
- `CSRF_TRUSTED_ORIGINS`
- `SIGNAL_NUMBER` (if Signal enabled)
- `WHATSAPP_ENABLED=true|false`
- `INSTAGRAM_ENABLED=true|false`
- XMPP values if XMPP bridge is enabled
### 2) Build and start
```bash
make build
make run
``` ```
3. Edit `stack.env` ### 3) Run migrations and create admin user
```bash
4. Build and start the containers make migrate
```shell make auth
make build
make run
``` ```
5. Run database migrations ### 4) Follow logs
```shell ```bash
make migrate make log
``` ```
6. Create a superuser for Django Admin (optional but recommended) ## Local developer commands
```shell
make auth ```bash
make makemigrations
make migrate
make test
``` ```
7. Monitor logs Run ad-hoc manage commands:
```shell ```bash
make log docker-compose --env-file=stack.env run --rm app \
sh -c ". /venv/bin/activate && python manage.py <command>"
``` ```
## ⚙️ Deployment & Architecture ## Service setup pages
### 🏗️ Services Overview
| Service | Description |
|----------------|-------------|
| **app** | Main application container running **Uvicorn** for API handling. |
| **db** | ManticoreSearch-based database backend. |
| **redis** | Message queue for task distribution. |
| **signal-cli** | Handles Signal communications. |
| **processing** | Processes incoming messages and executes manipulations. |
| **scheduling** | Handles timed tasks and influence scheduling. |
| **migration** | Runs database migrations automatically on startup. |
| **collectstatic** | Collects static files for Django before launch. |
## 🔥 Running Commands in a Container - Signal: `/services/signal/`
- WhatsApp: `/services/whatsapp/`
- Instagram: `/services/instagram/`
You can execute management commands inside the app container using: ### WhatsApp pairing (Neonize)
```shell WhatsApp account linking expects a real Neonize runtime QR:
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py <command>" 1. Start stack with `WHATSAPP_ENABLED=true`.
``` 2. Ensure `ur` is running and WhatsApp runtime initialized.
3. Open `Services -> WhatsApp -> Add Account`.
4. Scan QR from WhatsApp Linked Devices.
## 🛑 Stopping GIA If runtime QR is not available yet, UI returns a clear warning instead of a synthetic QR.
To stop all running services:
```shell
make stop
```
## 🕵️‍♂️ Operational Modes ## Manual compose mode
GIA runs in different operation modes set via OPERATION:
| Mode | Description |
|--------|-------------|
| **uwsgi** | Runs behind Nginx for production. |
| **dev** | Direct execution via Django's built-in server (development mode). |
- Route: `/compose/page/?service=<service>&identifier=<id>[&person=<uuid>]`
- Features:
- Chat thread view.
- Send with two-step failsafe (`Arm Send` + `Confirm Intent`).
- AI overlay tools:
- Draft suggestions.
- Conversation summary.
- Quick engage send (shared framing, with its own two-step failsafe).
- Click-outside dismiss behavior for AI overlays.
The default Podman entrypoint dynamically selects the correct process based on OPERATION. ### Live updates and traffic minimization
Be sure to uncomment nginx if using dev, as the shipped setup expects an external `nginx` instance to point to the GIA `uwsgi` sock: - WebSocket endpoint: `/ws/compose/thread/` (ASGI path).
``` - Compose clients open one persistent WS per panel and receive incremental updates.
location / { - HTTP polling remains as a fallback at a slower interval when WS is unavailable.
include include/xf-only.conf;
include /etc/nginx/uwsgi_params;
uwsgi_pass unix:///code/vrun/uwsgi-gia.sock;
uwsgi_param Host $host;
uwsgi_param X-Real-IP $remote_addr;
uwsgi_param X-Forwarded-For $proxy_add_x_forwarded_for;
uwsgi_param X-Forwarded-Proto $http_x_forwarded_proto;
}
location /static {
alias /code/xf/GIA/static/;
}
```
## 🔄 Persistent Data & Storage This reduces repeated client GET requests compared with short polling loops.
| Mount Path (Host) | Purpose |
|---------------------------|-------------|
| **docker/uwsgi.ini** | Configuration for **uWSGI** execution. |
| **db.sqlite3** | SQLite database storage. |
| **/code/vrun/** | Sockets shared between services. |
| **signal-cli-config/** | Stores **Signal CLI** configuration and keys. |
## 🔧 Additional Configuration ## AI workspace
### Django Environment Variables
The following are required for proper operation: Route: `/ai/workspace/`
```shell
APP_PORT=5006
REPO_DIR=.
APP_LOCAL_SETTINGS=./app/local_settings.py
APP_DATABASE_FILE=./db.sqlite3
DOMAIN=example.com
URL=https://example.com
ALLOWED_HOSTS=example.com
NOTIFY_TOPIC=example-topic
CSRF_TRUSTED_ORIGINS=https://example.com
DEBUG=y
SECRET_KEY=
STATIC_ROOT=/code/static
REGISTRATION_OPEN=0
OPERATION=uwsgi
BILLING_ENABLED=0
```
These can be set inside `stack.env`. Key capabilities:
- Summary, draft, pattern extraction.
- Mitigation planning and artifacts (rules/games/corrections).
- Insight metrics:
- Detail pages per metric.
- All-graphs page using Chart.js.
- Scoring help page describing formulas and interpretation.
## ⚠️ Legal Disclaimer ## Configuration notes
**GIA is provided for research and educational purposes only.** - `app/local_settings.py` is imported by `app/settings.py`.
The developers and contributors are not responsible for any **misuse, illegal activities, or consequences** resulting from the use of this software. - Redis cache is expected at `/var/run/gia-redis.sock` unless overridden.
- Service runtime URLs are read by `core/clients/transport.py`:
- `SIGNAL_HTTP_URL`
- `WHATSAPP_HTTP_URL`
- `INSTAGRAM_HTTP_URL`
By using GIA, you **acknowledge and agree** that: ## ASGI / WebSocket note
- You are solely responsible for your actions and compliance with applicable laws.
- The software should not be used for unauthorized surveillance, coercion, or unethical influence.
- The authors disclaim all liability for damages resulting from its use.
GIA is a tool that must be used responsibly. **Do not deploy it where it violates privacy laws, cybersecurity regulations, or ethical guidelines.** `app/asgi.py` now multiplexes:
- HTTP to Django ASGI app.
- WebSocket path `/ws/compose/thread/` to compose realtime handler.
Use an ASGI-capable server in environments where WebSockets are required.
## Troubleshooting
### “Send failed. Check service account state.”
- Verify service account is linked and runtime is connected.
- Check `ur` and service logs (`make log`).
### WhatsApp QR not available
- Confirm `WHATSAPP_ENABLED=true`.
- Confirm Neonize initializes in `ur` logs.
- Retry Add Account after runtime is ready.
### No compose realtime updates
- Verify ASGI deployment path and websocket upgrade support.
- Fallback polling should still update messages on interval.
## Security and operational safety
- Outbound actions in manual compose/engage are guarded by two-step failsafe toggles.
- Queue workflow exists for additional human approval in managed flows.
- Deploy only with lawful consent and compliant data handling for your jurisdiction.

View File

@@ -13,4 +13,17 @@ from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
application = get_asgi_application() django_asgi_app = get_asgi_application()
async def application(scope, receive, send):
if scope.get("type") == "websocket":
path = scope.get("path", "")
if path == "/ws/compose/thread/":
from core.realtime.compose_ws import compose_ws_application
await compose_ws_application(scope, receive, send)
return
await send({"type": "websocket.close", "code": 4404})
return
await django_asgi_app(scope, receive, send)

View File

@@ -96,6 +96,7 @@ MIDDLEWARE = [
] ]
ROOT_URLCONF = "app.urls" ROOT_URLCONF = "app.urls"
ASGI_APPLICATION = "app.asgi.application"
TEMPLATES = [ TEMPLATES = [
{ {

View File

@@ -30,6 +30,7 @@ from core.views import (
manipulations, manipulations,
messages, messages,
notifications, notifications,
osint,
people, people,
personas, personas,
queues, queues,
@@ -128,6 +129,26 @@ urlpatterns = [
compose.ComposeSend.as_view(), compose.ComposeSend.as_view(),
name="compose_send", name="compose_send",
), ),
path(
"compose/drafts/",
compose.ComposeDrafts.as_view(),
name="compose_drafts",
),
path(
"compose/summary/",
compose.ComposeSummary.as_view(),
name="compose_summary",
),
path(
"compose/engage/preview/",
compose.ComposeEngagePreview.as_view(),
name="compose_engage_preview",
),
path(
"compose/engage/send/",
compose.ComposeEngageSend.as_view(),
name="compose_engage_send",
),
path( path(
"compose/thread/", "compose/thread/",
compose.ComposeThread.as_view(), compose.ComposeThread.as_view(),
@@ -254,6 +275,11 @@ urlpatterns = [
ais.AIList.as_view(), ais.AIList.as_view(),
name="ais", name="ais",
), ),
path(
"search/<str:type>/",
osint.OSINTSearch.as_view(),
name="osint_search",
),
path( path(
"ai/<str:type>/create/", "ai/<str:type>/create/",
ais.AICreate.as_view(), ais.AICreate.as_view(),

View File

@@ -397,6 +397,12 @@ def get_link_qr(service: str, device_name: str):
if cached: if cached:
return cached return cached
if service_key == "whatsapp":
raise RuntimeError(
"Neonize has not provided a pairing QR yet. "
"Ensure UR is running with WHATSAPP_ENABLED=true and retry."
)
token = secrets.token_urlsafe(24) token = secrets.token_urlsafe(24)
uri = f"gia://{service_key}/link?device={device}&token={token}" uri = f"gia://{service_key}/link?device={device}&token={token}"
update_runtime_state( update_runtime_state(

162
core/realtime/compose_ws.py Normal file
View File

@@ -0,0 +1,162 @@
import asyncio
import json
import time
from datetime import datetime, timezone as dt_timezone
from urllib.parse import parse_qs
from asgiref.sync import sync_to_async
from django.core import signing
from core.models import ChatSession, Message, PersonIdentifier
from core.views.compose import COMPOSE_WS_TOKEN_SALT
def _safe_int(value, default=0):
try:
return int(value)
except (TypeError, ValueError):
return default
def _fmt_ts(ts_value):
try:
dt = datetime.fromtimestamp(int(ts_value) / 1000, tz=dt_timezone.utc)
return dt.strftime("%H:%M")
except Exception:
return str(ts_value or "")
def _serialize_message(msg):
author = str(msg.custom_author or "").strip()
return {
"id": str(msg.id),
"ts": int(msg.ts or 0),
"display_ts": _fmt_ts(msg.ts),
"text": str(msg.text or ""),
"author": author,
"outgoing": author.upper() in {"USER", "BOT"},
}
def _load_since(user_id, service, identifier, person_id, after_ts, limit):
person_identifier = None
if person_id:
person_identifier = (
PersonIdentifier.objects.filter(
user_id=user_id,
person_id=person_id,
service=service,
).first()
or PersonIdentifier.objects.filter(
user_id=user_id,
person_id=person_id,
).first()
)
if person_identifier is None and identifier:
person_identifier = PersonIdentifier.objects.filter(
user_id=user_id,
service=service,
identifier=identifier,
).first()
if person_identifier is None:
return {"messages": [], "last_ts": after_ts}
session = ChatSession.objects.filter(
user_id=user_id,
identifier=person_identifier,
).first()
if session is None:
return {"messages": [], "last_ts": after_ts}
qs = Message.objects.filter(
user_id=user_id,
session=session,
).order_by("ts")
if after_ts > 0:
qs = qs.filter(ts__gt=after_ts)
rows = list(qs[: max(10, min(limit, 200))])
newest = (
Message.objects.filter(user_id=user_id, session=session)
.order_by("-ts")
.values_list("ts", flat=True)
.first()
)
return {
"messages": [_serialize_message(row) for row in rows],
"last_ts": int(newest or after_ts or 0),
}
async def compose_ws_application(scope, receive, send):
if scope.get("type") != "websocket":
return
query_string = (scope.get("query_string") or b"").decode("utf-8", errors="ignore")
params = parse_qs(query_string)
token = (params.get("token") or [""])[0]
try:
payload = signing.loads(token, salt=COMPOSE_WS_TOKEN_SALT)
except Exception:
await send({"type": "websocket.close", "code": 4401})
return
if _safe_int(payload.get("exp")) < int(time.time()):
await send({"type": "websocket.close", "code": 4401})
return
user_id = _safe_int(payload.get("u"))
service = str(payload.get("s") or "").strip()
identifier = str(payload.get("i") or "").strip()
person_id = str(payload.get("p") or "").strip()
if user_id <= 0 or (not identifier and not person_id):
await send({"type": "websocket.close", "code": 4401})
return
await send({"type": "websocket.accept"})
last_ts = 0
limit = 100
while True:
event = None
try:
event = await asyncio.wait_for(receive(), timeout=1.2)
except asyncio.TimeoutError:
event = None
if event and event.get("type") == "websocket.disconnect":
break
if event and event.get("type") == "websocket.receive":
try:
body = json.loads(event.get("text") or "{}")
except Exception:
body = {}
if body.get("kind") == "sync":
last_ts = max(last_ts, _safe_int(body.get("last_ts"), 0))
payload = await sync_to_async(_load_since)(
user_id=user_id,
service=service,
identifier=identifier,
person_id=person_id,
after_ts=last_ts,
limit=limit,
)
messages = payload.get("messages") or []
latest = _safe_int(payload.get("last_ts"), last_ts)
if messages:
last_ts = max(last_ts, latest)
await send(
{
"type": "websocket.send",
"text": json.dumps(
{
"messages": messages,
"last_ts": last_ts,
}
),
}
)
else:
last_ts = max(last_ts, latest)

View File

@@ -212,6 +212,43 @@
z-index: 39 !important; z-index: 39 !important;
} }
.osint-table-shell {
border: 1px solid rgba(127, 127, 127, 0.2);
border-radius: 14px;
padding: 0.9rem;
background: rgba(255, 255, 255, 0.45);
}
.osint-table-toolbar {
margin-bottom: 0.75rem;
}
.osint-results-table-wrap {
border-radius: 10px;
overflow: auto;
}
.osint-results-table th {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.osint-sort-link {
color: inherit;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.osint-sort-link:hover {
color: #3273dc;
}
.osint-search-form .button.is-fullwidth {
width: 100%;
}
</style> </style>
</head> </head>
<body> <body>
@@ -235,6 +272,9 @@
Home Home
</a> </a>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<a class="navbar-item" href="{% url 'osint_search' type='page' %}">
Search
</a>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link"> <a class="navbar-link">

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block content %}
<div class="columns is-centered">
<div class="column is-11-tablet is-10-desktop is-9-widescreen">
<div class="is-flex is-justify-content-space-between is-align-items-center">
<div>
<h1 class="title is-4">Search</h1>
<p class="subtitle is-6">
Search across OSINT objects with sortable, paginated results.
</p>
</div>
<div>
<button
class="button is-light"
hx-get="{{ search_widget_url }}"
hx-target="#widgets-here"
hx-swap="beforeend"
onclick="document.getElementById('widgets-here').style.display = 'block';">
Open Widget
</button>
</div>
</div>
{% include "partials/osint/search-panel.html" %}
</div>
</div>
{% endblock %}

View File

@@ -14,6 +14,18 @@
</p> </p>
</div> </div>
<div class="buttons are-small" style="margin: 0;"> <div class="buttons are-small" style="margin: 0;">
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="drafts">
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
<span>Drafts</span>
</button>
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="summary">
<span class="icon is-small"><i class="fa-solid fa-list"></i></span>
<span>Summary</span>
</button>
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="engage">
<span class="icon is-small"><i class="fa-solid fa-handshake"></i></span>
<span>Engage</span>
</button>
<a class="button is-light is-rounded" href="{{ ai_workspace_url }}"> <a class="button is-light is-rounded" href="{{ ai_workspace_url }}">
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span> <span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
<span>AI Workspace</span> <span>AI Workspace</span>
@@ -25,6 +37,46 @@
{% include "partials/compose-send-status.html" %} {% include "partials/compose-send-status.html" %}
</div> </div>
<div id="{{ panel_id }}-popover" class="compose-ai-popover is-hidden">
<div class="compose-ai-card" data-kind="drafts">
<p class="compose-ai-title">Draft Suggestions</p>
<div class="compose-ai-loading">
<div class="compose-ai-skel"></div>
<div class="compose-ai-skel"></div>
<div class="compose-ai-skel"></div>
</div>
<div class="compose-ai-content"></div>
</div>
<div class="compose-ai-card" data-kind="summary">
<p class="compose-ai-title">Conversation Summary</p>
<div class="compose-ai-loading">
<div class="compose-ai-skel"></div>
<div class="compose-ai-skel"></div>
<div class="compose-ai-skel"></div>
</div>
<div class="compose-ai-content"></div>
</div>
<div class="compose-ai-card" data-kind="engage">
<p class="compose-ai-title">Quick Engage (Shared Framing)</p>
<div class="compose-ai-loading">
<div class="compose-ai-skel"></div>
<div class="compose-ai-skel"></div>
</div>
<div class="compose-ai-content"></div>
<div class="compose-ai-safety">
<label class="checkbox is-size-7">
<input type="checkbox" class="engage-arm"> Arm Send
</label>
<label class="checkbox is-size-7">
<input type="checkbox" class="engage-confirm"> Confirm Share To Other Party
</label>
<button type="button" class="button is-link is-light is-small engage-send-btn" disabled>
Send Engage
</button>
</div>
</div>
</div>
<div <div
id="{{ panel_id }}-thread" id="{{ panel_id }}-thread"
class="compose-thread" class="compose-thread"
@@ -33,7 +85,12 @@
data-identifier="{{ identifier }}" data-identifier="{{ identifier }}"
data-person="{% if person %}{{ person.id }}{% endif %}" data-person="{% if person %}{{ person.id }}{% endif %}"
data-limit="{{ limit }}" data-limit="{{ limit }}"
data-last-ts="{{ last_ts }}"> data-last-ts="{{ last_ts }}"
data-ws-url="{{ compose_ws_url }}"
data-drafts-url="{{ compose_drafts_url }}"
data-summary-url="{{ compose_summary_url }}"
data-engage-preview-url="{{ compose_engage_preview_url }}"
data-engage-send-url="{{ compose_engage_send_url }}">
{% for msg in serialized_messages %} {% for msg in serialized_messages %}
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}"> <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 %}"> <article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
@@ -59,9 +116,19 @@
<input type="hidden" name="identifier" value="{{ identifier }}"> <input type="hidden" name="identifier" value="{{ identifier }}">
<input type="hidden" name="render_mode" value="{{ render_mode }}"> <input type="hidden" name="render_mode" value="{{ render_mode }}">
<input type="hidden" name="limit" value="{{ limit }}"> <input type="hidden" name="limit" value="{{ limit }}">
<input type="hidden" name="failsafe_arm" value="0">
<input type="hidden" name="failsafe_confirm" value="0">
{% if person %} {% if person %}
<input type="hidden" name="person" value="{{ person.id }}"> <input type="hidden" name="person" value="{{ person.id }}">
{% endif %} {% endif %}
<div class="compose-send-safety">
<label class="checkbox is-size-7">
<input type="checkbox" class="manual-arm"> Arm Send
</label>
<label class="checkbox is-size-7">
<input type="checkbox" class="manual-confirm"> Confirm Intent
</label>
</div>
<div class="compose-composer-capsule"> <div class="compose-composer-capsule">
<textarea <textarea
id="{{ panel_id }}-textarea" id="{{ panel_id }}-textarea"
@@ -69,7 +136,7 @@
name="text" name="text"
rows="1" rows="1"
placeholder="Type a message. Enter to send, Shift+Enter for newline."></textarea> placeholder="Type a message. Enter to send, Shift+Enter for newline."></textarea>
<button class="button is-link is-light compose-send-btn" type="submit"> <button class="button is-link is-light compose-send-btn" type="submit" disabled>
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span> <span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
<span>Send</span> <span>Send</span>
</button> </button>
@@ -79,6 +146,7 @@
<style> <style>
#{{ panel_id }}.compose-shell { #{{ panel_id }}.compose-shell {
position: relative;
border: 1px solid rgba(0, 0, 0, 0.16); border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 8px; border-radius: 8px;
box-shadow: none; box-shadow: none;
@@ -164,10 +232,87 @@
border-radius: 8px; border-radius: 8px;
margin: 0; margin: 0;
} }
#{{ panel_id }} .compose-send-btn[disabled] {
opacity: 0.55;
}
#{{ panel_id }} .compose-send-safety {
display: flex;
gap: 0.85rem;
flex-wrap: wrap;
margin-bottom: 0.45rem;
color: #505050;
}
#{{ panel_id }} .compose-status { #{{ panel_id }} .compose-status {
margin-top: 0.55rem; margin-top: 0.55rem;
min-height: 1.1rem; min-height: 1.1rem;
} }
#{{ panel_id }} .compose-ai-popover {
position: absolute;
top: 4.2rem;
right: 0.7rem;
width: min(34rem, calc(100% - 1.4rem));
z-index: 25;
}
#{{ panel_id }} .compose-ai-popover.is-hidden {
display: none;
}
#{{ panel_id }} .compose-ai-card {
display: none;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 8px;
background: #fff;
padding: 0.65rem;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.08);
}
#{{ panel_id }} .compose-ai-card.is-active {
display: block;
animation: composeFadeIn 160ms ease-out;
}
#{{ panel_id }} .compose-ai-title {
font-weight: 600;
margin-bottom: 0.45rem;
}
#{{ panel_id }} .compose-ai-loading.is-hidden {
display: none;
}
#{{ panel_id }} .compose-ai-skel {
height: 0.7rem;
border-radius: 999px;
margin-bottom: 0.4rem;
background: linear-gradient(90deg, rgba(233, 236, 239, 0.8), rgba(210, 214, 218, 0.95), rgba(233, 236, 239, 0.8));
background-size: 200% 100%;
animation: composePulse 1s ease-in-out infinite;
}
#{{ panel_id }} .compose-ai-skel:last-child {
margin-bottom: 0;
}
#{{ panel_id }} .compose-ai-content {
white-space: pre-wrap;
}
#{{ panel_id }} .compose-draft-option {
width: 100%;
text-align: left;
margin-bottom: 0.45rem;
border-radius: 8px;
}
#{{ panel_id }} .compose-draft-option:last-child {
margin-bottom: 0;
}
#{{ panel_id }} .compose-ai-safety {
margin-top: 0.55rem;
display: flex;
gap: 0.65rem;
flex-wrap: wrap;
align-items: center;
}
@keyframes composePulse {
0% { background-position: 100% 0; }
100% { background-position: 0 0; }
}
@keyframes composeFadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) { @media (max-width: 768px) {
#{{ panel_id }} .compose-thread { #{{ panel_id }} .compose-thread {
max-height: 52vh; max-height: 52vh;
@@ -175,6 +320,11 @@
#{{ panel_id }} .compose-send-btn span:last-child { #{{ panel_id }} .compose-send-btn span:last-child {
display: none; display: none;
} }
#{{ panel_id }} .compose-ai-popover {
left: 0.7rem;
right: 0.7rem;
width: auto;
}
} }
</style> </style>
@@ -192,6 +342,10 @@
return; return;
} }
const statusBox = document.getElementById(panelId + "-status");
const popover = document.getElementById(panelId + "-popover");
const csrfToken = "{{ csrf_token }}";
window.giaComposePanels = window.giaComposePanels || {}; window.giaComposePanels = window.giaComposePanels || {};
const previousState = window.giaComposePanels[panelId]; const previousState = window.giaComposePanels[panelId];
if (previousState && previousState.timer) { if (previousState && previousState.timer) {
@@ -200,7 +354,17 @@
if (previousState && previousState.eventHandler) { if (previousState && previousState.eventHandler) {
document.body.removeEventListener("composeMessageSent", previousState.eventHandler); document.body.removeEventListener("composeMessageSent", previousState.eventHandler);
} }
const panelState = { timer: null, polling: false }; if (previousState && previousState.docClickHandler) {
document.removeEventListener("mousedown", previousState.docClickHandler);
}
const panelState = {
timer: null,
polling: false,
socket: null,
websocketReady: false,
activePanel: null,
engageToken: ""
};
window.giaComposePanels[panelId] = panelState; window.giaComposePanels[panelId] = panelState;
const toInt = function (value) { const toInt = function (value) {
@@ -259,8 +423,20 @@
thread.appendChild(row); thread.appendChild(row);
}; };
const appendMessages = function (messages, forceScroll) {
const shouldStick = nearBottom() || forceScroll;
(messages || []).forEach(function (msg) {
appendBubble(msg);
lastTs = Math.max(lastTs, toInt(msg.ts));
});
thread.dataset.lastTs = String(lastTs);
if ((messages || []).length > 0) {
scrollToBottom(shouldStick);
}
};
const poll = async function (forceScroll) { const poll = async function (forceScroll) {
if (panelState.polling) { if (panelState.polling || panelState.websocketReady) {
return; return;
} }
panelState.polling = true; panelState.polling = true;
@@ -273,28 +449,19 @@
} }
params.set("limit", thread.dataset.limit || "60"); params.set("limit", thread.dataset.limit || "60");
params.set("after_ts", String(lastTs)); params.set("after_ts", String(lastTs));
const url = thread.dataset.pollUrl + "?" + params.toString(); const response = await fetch(thread.dataset.pollUrl + "?" + params.toString(), {
const response = await fetch(url, {
method: "GET", method: "GET",
credentials: "same-origin", credentials: "same-origin",
headers: { Accept: "application/json" }, headers: { Accept: "application/json" }
}); });
if (!response.ok) { if (!response.ok) {
return; return;
} }
const payload = await response.json(); const payload = await response.json();
const messages = Array.isArray(payload.messages) ? payload.messages : []; appendMessages(payload.messages || [], forceScroll);
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) { if (payload.last_ts !== undefined && payload.last_ts !== null) {
lastTs = Math.max(lastTs, toInt(payload.last_ts)); lastTs = Math.max(lastTs, toInt(payload.last_ts));
}
thread.dataset.lastTs = String(lastTs); thread.dataset.lastTs = String(lastTs);
if (messages.length > 0) {
scrollToBottom(shouldStick);
} }
} catch (err) { } catch (err) {
console.debug("compose poll error", err); console.debug("compose poll error", err);
@@ -303,9 +470,324 @@
} }
}; };
const setupWebSocket = function () {
const wsPath = thread.dataset.wsUrl || "";
if (!wsPath || !window.WebSocket) {
return;
}
const protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
const socketUrl = protocol + window.location.host + wsPath;
try {
const socket = new WebSocket(socketUrl);
panelState.socket = socket;
socket.onopen = function () {
panelState.websocketReady = true;
try {
socket.send(JSON.stringify({ kind: "sync", last_ts: lastTs }));
} catch (err) {
// Ignore.
}
};
socket.onmessage = function (event) {
try {
const payload = JSON.parse(event.data || "{}");
appendMessages(payload.messages || [], false);
if (payload.last_ts !== undefined && payload.last_ts !== null) {
lastTs = Math.max(lastTs, toInt(payload.last_ts));
thread.dataset.lastTs = String(lastTs);
}
} catch (err) {
console.debug("compose websocket payload error", err);
}
};
socket.onclose = function () {
panelState.websocketReady = false;
};
socket.onerror = function () {
panelState.websocketReady = false;
};
} catch (err) {
panelState.websocketReady = false;
}
};
const manualArm = form.querySelector(".manual-arm");
const manualConfirm = form.querySelector(".manual-confirm");
const armInput = form.querySelector("input[name='failsafe_arm']");
const confirmInput = form.querySelector("input[name='failsafe_confirm']");
const sendButton = form.querySelector(".compose-send-btn");
const updateManualSafety = function () {
const arm = !!(manualArm && manualArm.checked);
const confirm = !!(manualConfirm && manualConfirm.checked);
if (armInput) {
armInput.value = arm ? "1" : "0";
}
if (confirmInput) {
confirmInput.value = confirm ? "1" : "0";
}
if (sendButton) {
sendButton.disabled = !(arm && confirm);
}
};
if (manualArm) {
manualArm.addEventListener("change", updateManualSafety);
}
if (manualConfirm) {
manualConfirm.addEventListener("change", updateManualSafety);
}
updateManualSafety();
const setStatus = function (message, level) {
if (!statusBox) {
return;
}
if (!message) {
statusBox.innerHTML = "";
return;
}
statusBox.innerHTML = '<article class="notification is-' + (level || "info") + ' is-light" style="padding:0.45rem 0.65rem; margin:0;">' + message + "</article>";
};
const hideAllCards = function () {
if (!popover) {
return;
}
popover.classList.add("is-hidden");
popover.querySelectorAll(".compose-ai-card").forEach(function (card) {
card.classList.remove("is-active");
});
panelState.activePanel = null;
};
const showCard = function (kind) {
if (!popover) {
return null;
}
popover.classList.remove("is-hidden");
let active = null;
popover.querySelectorAll(".compose-ai-card").forEach(function (card) {
const isActive = card.dataset.kind === kind;
card.classList.toggle("is-active", isActive);
if (isActive) {
active = card;
}
});
panelState.activePanel = kind;
return active;
};
const queryParams = function () {
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");
return params;
};
const setCardLoading = function (card, loading) {
const loadingNode = card.querySelector(".compose-ai-loading");
const contentNode = card.querySelector(".compose-ai-content");
if (loadingNode) {
loadingNode.classList.toggle("is-hidden", !loading);
}
if (contentNode && loading) {
contentNode.innerHTML = "";
}
};
const loadDrafts = async function () {
const card = showCard("drafts");
if (!card) {
return;
}
setCardLoading(card, true);
try {
const response = await fetch(thread.dataset.draftsUrl + "?" + queryParams().toString(), {
method: "GET",
credentials: "same-origin",
headers: { Accept: "application/json" }
});
const payload = await response.json();
setCardLoading(card, false);
if (!payload.ok) {
card.querySelector(".compose-ai-content").textContent = payload.error || "Failed to load drafts.";
return;
}
const drafts = Array.isArray(payload.drafts) ? payload.drafts : [];
const container = card.querySelector(".compose-ai-content");
container.innerHTML = "";
drafts.forEach(function (item) {
const button = document.createElement("button");
button.type = "button";
button.className = "button is-light compose-draft-option";
const strong = document.createElement("strong");
strong.textContent = String(item.label || "Option") + ": ";
const span = document.createElement("span");
span.textContent = String(item.text || "");
button.appendChild(strong);
button.appendChild(span);
button.addEventListener("click", function () {
textarea.value = String(item.text || "");
autosize();
textarea.focus();
hideAllCards();
});
container.appendChild(button);
});
} catch (err) {
setCardLoading(card, false);
card.querySelector(".compose-ai-content").textContent = "Failed to load drafts.";
}
};
const loadSummary = async function () {
const card = showCard("summary");
if (!card) {
return;
}
setCardLoading(card, true);
try {
const response = await fetch(thread.dataset.summaryUrl + "?" + queryParams().toString(), {
method: "GET",
credentials: "same-origin",
headers: { Accept: "application/json" }
});
const payload = await response.json();
setCardLoading(card, false);
if (!payload.ok) {
card.querySelector(".compose-ai-content").textContent = payload.error || "Failed to load summary.";
return;
}
card.querySelector(".compose-ai-content").textContent = String(payload.summary || "");
} catch (err) {
setCardLoading(card, false);
card.querySelector(".compose-ai-content").textContent = "Failed to load summary.";
}
};
const bindEngageControls = function (card) {
const arm = card.querySelector(".engage-arm");
const confirm = card.querySelector(".engage-confirm");
const send = card.querySelector(".engage-send-btn");
const sync = function () {
send.disabled = !(arm.checked && confirm.checked && panelState.engageToken);
};
arm.addEventListener("change", sync);
confirm.addEventListener("change", sync);
send.addEventListener("click", async function () {
if (!panelState.engageToken) {
return;
}
const formData = new URLSearchParams();
formData.set("service", thread.dataset.service || "");
formData.set("identifier", thread.dataset.identifier || "");
if (thread.dataset.person) {
formData.set("person", thread.dataset.person);
}
formData.set("engage_token", panelState.engageToken);
formData.set("failsafe_arm", arm.checked ? "1" : "0");
formData.set("failsafe_confirm", confirm.checked ? "1" : "0");
try {
const response = await fetch(thread.dataset.engageSendUrl, {
method: "POST",
credentials: "same-origin",
headers: {
"X-CSRFToken": csrfToken,
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json"
},
body: formData.toString()
});
const payload = await response.json();
if (!payload.ok) {
setStatus(payload.error || "Engage send failed.", "danger");
return;
}
setStatus(payload.message || "Shared engage sent.", "success");
hideAllCards();
poll(true);
} catch (err) {
setStatus("Engage send failed.", "danger");
}
});
};
const loadEngage = async function () {
const card = showCard("engage");
if (!card) {
return;
}
setCardLoading(card, true);
panelState.engageToken = "";
const sendBtn = card.querySelector(".engage-send-btn");
const arm = card.querySelector(".engage-arm");
const confirm = card.querySelector(".engage-confirm");
arm.checked = false;
confirm.checked = false;
sendBtn.disabled = true;
try {
const response = await fetch(thread.dataset.engagePreviewUrl + "?" + queryParams().toString(), {
method: "GET",
credentials: "same-origin",
headers: { Accept: "application/json" }
});
const payload = await response.json();
setCardLoading(card, false);
if (!payload.ok) {
card.querySelector(".compose-ai-content").textContent = payload.error || "Failed to load engage preview.";
return;
}
panelState.engageToken = String(payload.token || "");
let text = String(payload.preview || "");
if (payload.artifact) {
text = text + "\n\nSource: " + String(payload.artifact);
}
card.querySelector(".compose-ai-content").textContent = text;
sendBtn.disabled = true;
} catch (err) {
setCardLoading(card, false);
card.querySelector(".compose-ai-content").textContent = "Failed to load engage preview.";
}
if (!card.dataset.bound) {
bindEngageControls(card);
card.dataset.bound = "1";
}
};
panel.querySelectorAll(".js-ai-trigger").forEach(function (button) {
button.addEventListener("click", function () {
const kind = button.dataset.kind;
if (panelState.activePanel === kind) {
hideAllCards();
return;
}
if (kind === "drafts") {
loadDrafts();
} else if (kind === "summary") {
loadSummary();
} else if (kind === "engage") {
loadEngage();
}
});
});
panelState.docClickHandler = function (event) {
if (!panel.contains(event.target)) {
hideAllCards();
}
};
document.addEventListener("mousedown", panelState.docClickHandler);
textarea.addEventListener("keydown", function (event) { textarea.addEventListener("keydown", function (event) {
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
if (sendButton && sendButton.disabled) {
setStatus("Enable both send safety switches before sending.", "warning");
return;
}
form.requestSubmit(); form.requestSubmit();
} }
}); });
@@ -325,17 +807,23 @@
document.body.addEventListener("composeMessageSent", panelState.eventHandler); document.body.addEventListener("composeMessageSent", panelState.eventHandler);
scrollToBottom(true); scrollToBottom(true);
setupWebSocket();
panelState.timer = setInterval(function () { panelState.timer = setInterval(function () {
if (!document.getElementById(panelId)) { if (!document.getElementById(panelId)) {
clearInterval(panelState.timer); clearInterval(panelState.timer);
document.body.removeEventListener( document.body.removeEventListener("composeMessageSent", panelState.eventHandler);
"composeMessageSent", document.removeEventListener("mousedown", panelState.docClickHandler);
panelState.eventHandler if (panelState.socket) {
); try {
panelState.socket.close();
} catch (err) {
// Ignore.
}
}
delete window.giaComposePanels[panelId]; delete window.giaComposePanels[panelId];
return; return;
} }
poll(false); poll(false);
}, 1800); }, 4000);
})(); })();
</script> </script>

View File

@@ -0,0 +1,225 @@
{% include 'mixins/partials/notify.html' %}
<div
id="{{ osint_table_id }}"
class="osint-table-shell"
hx-get="{{ osint_refresh_url }}"
hx-target="#{{ osint_table_id }}"
hx-swap="outerHTML"
{% if osint_event_name %}hx-trigger="{{ osint_event_name }} from:body"{% endif %}>
{% if osint_show_search %}
<form
method="get"
action="{{ osint_search_url }}"
class="osint-table-toolbar"
hx-get="{{ osint_search_url }}"
hx-target="#{{ osint_table_id }}"
hx-swap="outerHTML">
<div class="field has-addons is-flex-wrap-wrap">
<div class="control is-expanded" style="min-width: 14rem;">
<input
class="input"
type="text"
name="q"
value="{{ osint_search_query }}"
placeholder="Search {{ osint_title|lower }}...">
</div>
<div class="control">
<div class="select">
<select name="field">
{% for field in osint_search_fields %}
<option
value="{{ field.value }}"
{% if field.value == osint_search_field %}selected{% endif %}>
{{ field.label }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<button class="button is-link is-light" type="submit">
Search
</button>
</div>
<div class="control">
<a class="button is-light" href="{{ osint_search_url }}">
Reset
</a>
</div>
</div>
</form>
{% endif %}
<div class="table-container osint-results-table-wrap">
<table class="table is-fullwidth is-hoverable osint-results-table">
<thead>
<tr>
{% for column in osint_columns %}
<th>
{% if column.sortable %}
<a
class="osint-sort-link"
href="{{ column.sort_url }}"
hx-get="{{ column.sort_url }}"
hx-target="#{{ osint_table_id }}"
hx-swap="outerHTML">
<span>{{ column.label }}</span>
<span class="icon is-small">
{% if column.is_sorted and column.is_desc %}
<i class="fa-solid fa-sort-down"></i>
{% elif column.is_sorted %}
<i class="fa-solid fa-sort-up"></i>
{% else %}
<i class="fa-solid fa-sort"></i>
{% endif %}
</span>
</a>
{% else %}
{{ column.label }}
{% endif %}
</th>
{% endfor %}
{% if osint_show_actions %}<th>Actions</th>{% endif %}
</tr>
</thead>
<tbody>
{% for row in osint_rows %}
<tr>
{% for cell in row.cells %}
<td>
{% if cell.kind == "id_copy" %}
<a
class="button is-small has-text-grey"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ cell.value }}');">
<span class="icon">
<i class="fa-solid fa-copy"></i>
</span>
<span>{{ cell.value }}</span>
</a>
{% elif cell.kind == "bool" %}
{% if cell.value %}
<span class="icon has-text-success">
<i class="fa-solid fa-check"></i>
</span>
{% else %}
<span class="icon has-text-grey">
<i class="fa-solid fa-xmark"></i>
</span>
{% endif %}
{% elif cell.kind == "datetime" %}
{% if cell.value %}
{{ cell.value|date:"M j, Y P" }}
{% else %}
<span class="has-text-grey">-</span>
{% endif %}
{% else %}
{% if cell.value or cell.value == 0 %}
{{ cell.value }}
{% else %}
<span class="has-text-grey">-</span>
{% endif %}
{% endif %}
</td>
{% endfor %}
{% if osint_show_actions %}
<td>
<div class="buttons are-small">
{% for action in row.actions %}
{% if action.mode == "hx-get" %}
<button
class="button"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ action.url }}"
hx-target="{{ action.target }}"
hx-swap="innerHTML"
title="{{ action.title }}">
<span class="icon"><i class="{{ action.icon }}"></i></span>
</button>
{% elif action.mode == "hx-delete" %}
<button
class="button"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete="{{ action.url }}"
hx-target="{{ action.target }}"
hx-swap="innerHTML"
{% if action.confirm %}hx-confirm="{{ action.confirm }}"{% endif %}
title="{{ action.title }}">
<span class="icon"><i class="{{ action.icon }}"></i></span>
</button>
{% elif action.mode == "link" %}
<a class="button" href="{{ action.url }}" title="{{ action.title }}">
<span class="icon"><i class="{{ action.icon }}"></i></span>
</a>
{% endif %}
{% endfor %}
</div>
</td>
{% endif %}
</tr>
{% empty %}
<tr>
<td colspan="{% if osint_show_actions %}{{ osint_columns|length|add:'1' }}{% else %}{{ osint_columns|length }}{% endif %}">
<p class="has-text-grey">No results found.</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if osint_pagination.enabled %}
<nav class="pagination is-small" role="navigation" aria-label="pagination">
{% if osint_pagination.has_previous %}
<a
class="pagination-previous"
href="{{ osint_pagination.previous_url }}"
hx-get="{{ osint_pagination.previous_url }}"
hx-target="#{{ osint_table_id }}"
hx-swap="outerHTML">
Previous
</a>
{% else %}
<a class="pagination-previous is-disabled">Previous</a>
{% endif %}
{% if osint_pagination.has_next %}
<a
class="pagination-next"
href="{{ osint_pagination.next_url }}"
hx-get="{{ osint_pagination.next_url }}"
hx-target="#{{ osint_table_id }}"
hx-swap="outerHTML">
Next
</a>
{% else %}
<a class="pagination-next is-disabled">Next</a>
{% endif %}
<ul class="pagination-list">
{% for page in osint_pagination.pages %}
{% if page.ellipsis %}
<li><span class="pagination-ellipsis">&hellip;</span></li>
{% elif page.current %}
<li><a class="pagination-link is-current">{{ page.number }}</a></li>
{% else %}
<li>
<a
class="pagination-link"
href="{{ page.url }}"
hx-get="{{ page.url }}"
hx-target="#{{ osint_table_id }}"
hx-swap="outerHTML">
{{ page.number }}
</a>
</li>
{% endif %}
{% endfor %}
</ul>
</nav>
{% endif %}
<p class="help has-text-grey">
{{ osint_result_count }} result{% if osint_result_count != 1 %}s{% endif %}
</p>
</div>

View File

@@ -0,0 +1,78 @@
<div class="box">
<form
class="osint-search-form"
method="get"
action="{{ osint_search_url }}"
hx-get="{{ osint_search_url }}"
hx-target="#osint-search-results"
hx-swap="innerHTML">
<div class="columns is-multiline">
<div class="column is-4">
<label class="label">Scope</label>
<div class="select is-fullwidth">
<select name="scope">
{% for option in scope_options %}
<option
value="{{ option.value }}"
{% if option.value == selected_scope %}selected{% endif %}>
{{ option.label }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-4">
<label class="label">Field</label>
<div class="select is-fullwidth">
<select name="field">
<option value="__all__" {% if selected_field == "__all__" %}selected{% endif %}>
All Fields
</option>
{% for option in field_options %}
<option
value="{{ option.value }}"
{% if option.value == selected_field %}selected{% endif %}>
{{ option.label }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-4">
<label class="label">Rows Per Page</label>
<div class="select is-fullwidth">
<select name="per_page">
<option value="10" {% if selected_per_page == 10 %}selected{% endif %}>10</option>
<option value="20" {% if selected_per_page == 20 %}selected{% endif %}>20</option>
<option value="50" {% if selected_per_page == 50 %}selected{% endif %}>50</option>
<option value="100" {% if selected_per_page == 100 %}selected{% endif %}>100</option>
</select>
</div>
</div>
<div class="column is-9">
<label class="label">Search Query</label>
<input
class="input"
type="text"
name="q"
value="{{ search_query }}"
placeholder="Search text, values, or relation names...">
</div>
<div class="column is-3">
<label class="label">&nbsp;</label>
<div class="buttons">
<button class="button is-link is-light is-fullwidth" type="submit">
Search
</button>
<a class="button is-light is-fullwidth" href="{{ osint_search_url }}">
Reset
</a>
</div>
</div>
</div>
</form>
<div id="osint-search-results">
{% include "partials/results_table.html" %}
</div>
</div>

View File

@@ -0,0 +1 @@
{% include "partials/osint/list-table.html" %}

View File

@@ -0,0 +1,14 @@
{% if object.ok %}
<img src="data:image/png;base64, {{ object.image_b64 }}" alt="WhatsApp QR code" />
{% if object.warning %}
<p class="is-size-7" style="margin-top: 0.6rem;">{{ object.warning }}</p>
{% endif %}
{% else %}
<article class="notification is-warning is-light" style="margin-bottom: 0;">
<p><strong>WhatsApp QR Not Ready.</strong></p>
<p>{{ object.error|default:"No Neonize pairing QR is available yet." }}</p>
{% if object.warning %}
<p style="margin-top: 0.45rem;">{{ object.warning }}</p>
{% endif %}
</article>
{% endif %}

View File

@@ -1,12 +1,15 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import re
import time import time
from datetime import datetime, timezone as dt_timezone from datetime import datetime, timezone as dt_timezone
from urllib.parse import urlencode from urllib.parse import urlencode
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.core import signing
from django.core.cache import cache
from django.http import HttpResponseBadRequest, JsonResponse from django.http import HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from django.urls import reverse from django.urls import reverse
@@ -14,7 +17,21 @@ from django.utils import timezone as dj_timezone
from django.views import View from django.views import View
from core.clients import transport from core.clients import transport
from core.models import ChatSession, Message, Person, PersonIdentifier from core.messaging import ai as ai_runner
from core.messaging.utils import messages_to_string
from core.models import (
AI,
ChatSession,
Message,
PatternMitigationPlan,
Person,
PersonIdentifier,
)
from core.views.workspace import _build_engage_payload, _parse_draft_options
COMPOSE_WS_TOKEN_SALT = "compose-ws"
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
COMPOSE_AI_CACHE_TTL = 60 * 30
def _default_service(service: str | None) -> str: def _default_service(service: str | None) -> str:
@@ -64,6 +81,185 @@ def _serialize_message(msg: Message) -> dict:
} }
def _owner_name(user) -> str:
return (
user.first_name
or user.get_full_name().strip()
or user.username
or "Me"
)
def _compose_ws_token(user_id, service, identifier, person_id):
payload = {
"u": int(user_id),
"s": str(service or ""),
"i": str(identifier or ""),
"p": str(person_id) if person_id else "",
"exp": int(time.time()) + (60 * 60 * 12),
}
return signing.dumps(payload, salt=COMPOSE_WS_TOKEN_SALT)
def _compose_ai_cache_key(kind, user_id, service, identifier, person_id, last_ts, limit):
raw = "|".join(
[
str(kind or ""),
str(user_id),
str(service or ""),
str(identifier or ""),
str(person_id or ""),
str(last_ts or 0),
str(limit or 0),
]
)
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()
return f"compose:{kind}:{digest}"
def _plain_text(value):
cleaned = re.sub(r"\s+", " ", str(value or "").strip())
cleaned = re.sub(r"^\s*#{1,6}\s*", "", cleaned)
cleaned = re.sub(r"\*\*(.*?)\*\*", r"\1", cleaned)
cleaned = re.sub(r"`(.*?)`", r"\1", cleaned)
return cleaned.strip()
def _engage_body_only(value):
lines = [line.strip() for line in str(value or "").splitlines() if line.strip()]
if lines and lines[0].startswith("**"):
lines = lines[1:]
if lines and lines[0].lower() == "guidance:":
lines = lines[1:]
return _plain_text(" ".join(lines))
def _messages_for_ai(user, person_identifier, limit):
if person_identifier is None:
return []
session, _ = ChatSession.objects.get_or_create(user=user, identifier=person_identifier)
rows = list(
Message.objects.filter(user=user, session=session)
.select_related("session", "session__identifier", "session__identifier__person")
.order_by("-ts")[:limit]
)
rows.reverse()
return rows
def _fallback_drafts():
return [
{
"label": "Soft",
"text": "I want us to stay connected. I am listening and I want to understand your perspective clearly.",
},
{
"label": "Neutral",
"text": "I hear your point. Let us clarify what each of us means so we can move forward constructively.",
},
{
"label": "Firm",
"text": "I want to resolve this respectfully. I will continue when we can keep the conversation constructive.",
},
]
def _build_draft_prompt(owner_name, person_name, transcript):
return [
{
"role": "system",
"content": (
"Generate exactly three short reply drafts for a chat. "
"Return labels Soft, Neutral, Firm. "
"Format:\nSoft: ...\nNeutral: ...\nFirm: ...\n"
"Each draft must be one to two sentences, plain text, no markdown."
),
},
{
"role": "user",
"content": (
f"Me: {owner_name}\n"
f"Other: {person_name}\n"
f"Conversation:\n{transcript}"
),
},
]
def _build_summary_prompt(owner_name, person_name, transcript):
return [
{
"role": "system",
"content": (
"Create a concise conversation summary with three sections. "
"Use this exact structure:\n"
"Headlines:\n- ...\n"
"Patterns:\n- ...\n"
"Suggested Next Message:\n- ...\n"
"Keep each bullet practical and specific."
),
},
{
"role": "user",
"content": (
f"Me: {owner_name}\n"
f"Other: {person_name}\n"
f"Conversation:\n{transcript}"
),
},
]
def _build_engage_prompt(owner_name, person_name, transcript):
return [
{
"role": "system",
"content": (
"Write one short de-escalating outreach in shared framing. "
"Use 'we/us/our' only. No names. One or two sentences."
),
},
{
"role": "user",
"content": (
f"Me: {owner_name}\n"
f"Other: {person_name}\n"
f"Conversation:\n{transcript}"
),
},
]
def _latest_plan_for_person(user, person):
if person is None:
return None
conversation = (
PatternMitigationPlan.objects.filter(
user=user,
conversation__participants=person,
)
.select_related("conversation")
.order_by("-updated_at")
.first()
)
return conversation
def _best_engage_source(plan):
if plan is None:
return (None, "")
correction = plan.corrections.order_by("-created_at").first()
if correction:
return (correction, "correction")
rule = plan.rules.order_by("-created_at").first()
if rule:
return (rule, "rule")
game = plan.games.order_by("-created_at").first()
if game:
return (game, "game")
return (None, "")
def _context_base(user, service, identifier, person): def _context_base(user, service, identifier, person):
person_identifier = None person_identifier = None
if person is not None: if person is not None:
@@ -143,6 +339,13 @@ def _panel_context(
base["identifier"], base["identifier"],
base["person"].id if base["person"] else None, base["person"].id if base["person"] else None,
) )
ws_token = _compose_ws_token(
user_id=request.user.id,
service=base["service"],
identifier=base["identifier"],
person_id=base["person"].id if base["person"] else None,
)
ws_url = f"/ws/compose/thread/?{urlencode({'token': ws_token})}"
unique_raw = f"{base['service']}|{base['identifier']}|{request.user.id}" unique_raw = f"{base['service']}|{base['identifier']}|{request.user.id}"
unique = hashlib.sha1(unique_raw.encode("utf-8")).hexdigest()[:12] unique = hashlib.sha1(unique_raw.encode("utf-8")).hexdigest()[:12]
@@ -164,6 +367,11 @@ def _panel_context(
"render_mode": render_mode, "render_mode": render_mode,
"compose_page_url": urls["page_url"], "compose_page_url": urls["page_url"],
"compose_widget_url": urls["widget_url"], "compose_widget_url": urls["widget_url"],
"compose_drafts_url": reverse("compose_drafts"),
"compose_summary_url": reverse("compose_summary"),
"compose_engage_preview_url": reverse("compose_engage_preview"),
"compose_engage_send_url": reverse("compose_engage_send"),
"compose_ws_url": ws_url,
"ai_workspace_url": ( "ai_workspace_url": (
f"{reverse('ai_workspace')}?person={base['person'].id}" f"{reverse('ai_workspace')}?person={base['person'].id}"
if base["person"] if base["person"]
@@ -305,6 +513,285 @@ class ComposeThread(LoginRequiredMixin, View):
return JsonResponse(payload) return JsonResponse(payload)
class ComposeDrafts(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 JsonResponse({"ok": False, "error": "Missing contact identifier."})
base = _context_base(request.user, service, identifier, person)
limit = _safe_limit(request.GET.get("limit") or 60)
messages = _messages_for_ai(request.user, base["person_identifier"], limit)
if not messages:
return JsonResponse(
{
"ok": True,
"cached": False,
"drafts": _fallback_drafts(),
}
)
last_ts = int(messages[-1].ts or 0)
cache_key = _compose_ai_cache_key(
"drafts",
request.user.id,
base["service"],
base["identifier"],
base["person"].id if base["person"] else "",
last_ts,
limit,
)
cached = cache.get(cache_key)
if cached:
return JsonResponse({"ok": True, "cached": True, "drafts": cached})
ai_obj = AI.objects.filter(user=request.user).first()
transcript = messages_to_string(
messages,
author_rewrites={
"USER": _owner_name(request.user),
"BOT": "Assistant",
},
)
drafts = _fallback_drafts()
if ai_obj is not None:
try:
result = async_to_sync(ai_runner.run_prompt)(
_build_draft_prompt(
owner_name=_owner_name(request.user),
person_name=base["person"].name if base["person"] else "Other",
transcript=transcript,
),
ai_obj,
)
parsed = _parse_draft_options(result)
if parsed:
drafts = parsed
except Exception:
pass
cache.set(cache_key, drafts, timeout=COMPOSE_AI_CACHE_TTL)
return JsonResponse({"ok": True, "cached": False, "drafts": drafts})
class ComposeSummary(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 JsonResponse({"ok": False, "error": "Missing contact identifier."})
base = _context_base(request.user, service, identifier, person)
limit = _safe_limit(request.GET.get("limit") or 60)
messages = _messages_for_ai(request.user, base["person_identifier"], limit)
if not messages:
return JsonResponse({"ok": True, "cached": False, "summary": ""})
last_ts = int(messages[-1].ts or 0)
cache_key = _compose_ai_cache_key(
"summary",
request.user.id,
base["service"],
base["identifier"],
base["person"].id if base["person"] else "",
last_ts,
limit,
)
cached = cache.get(cache_key)
if cached:
return JsonResponse({"ok": True, "cached": True, "summary": cached})
ai_obj = AI.objects.filter(user=request.user).first()
transcript = messages_to_string(
messages,
author_rewrites={
"USER": _owner_name(request.user),
"BOT": "Assistant",
},
)
if ai_obj is None:
fallback = (
"Headlines:\n"
"- Conversation loaded.\n"
"Patterns:\n"
"- Not enough AI context configured yet.\n"
"Suggested Next Message:\n"
"- I want us to keep this clear and constructive."
)
cache.set(cache_key, fallback, timeout=COMPOSE_AI_CACHE_TTL)
return JsonResponse({"ok": True, "cached": False, "summary": fallback})
try:
summary = async_to_sync(ai_runner.run_prompt)(
_build_summary_prompt(
owner_name=_owner_name(request.user),
person_name=base["person"].name if base["person"] else "Other",
transcript=transcript,
),
ai_obj,
)
except Exception as exc:
return JsonResponse({"ok": False, "error": str(exc)})
summary = str(summary or "").strip()
cache.set(cache_key, summary, timeout=COMPOSE_AI_CACHE_TTL)
return JsonResponse({"ok": True, "cached": False, "summary": summary})
class ComposeEngagePreview(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 JsonResponse({"ok": False, "error": "Missing contact identifier."})
base = _context_base(request.user, service, identifier, person)
limit = _safe_limit(request.GET.get("limit") or 60)
messages = _messages_for_ai(request.user, base["person_identifier"], limit)
transcript = messages_to_string(
messages,
author_rewrites={
"USER": _owner_name(request.user),
"BOT": "Assistant",
},
)
owner_name = _owner_name(request.user)
recipient_name = base["person"].name if base["person"] else "Other"
plan = _latest_plan_for_person(request.user, base["person"])
source_obj, source_kind = _best_engage_source(plan)
preview = ""
outbound = ""
artifact_label = "AI-generated"
if source_obj is not None:
payload = _build_engage_payload(
source_obj=source_obj,
source_kind=source_kind,
share_target="other",
framing="shared",
context_note="",
owner_name=owner_name,
recipient_name=recipient_name,
)
preview = str(payload.get("preview") or "").strip()
outbound = _engage_body_only(payload.get("outbound") or "")
artifact_label = f"{source_kind.title()}: {getattr(source_obj, 'title', '')}"
else:
ai_obj = AI.objects.filter(user=request.user).first()
if ai_obj is not None:
try:
generated = async_to_sync(ai_runner.run_prompt)(
_build_engage_prompt(owner_name, recipient_name, transcript),
ai_obj,
)
outbound = _plain_text(generated)
except Exception:
outbound = ""
if not outbound:
outbound = (
"We should slow down, clarify what we mean, and respond with care."
)
preview = f"**Shared Engage** (Correction)\n\nGuidance:\n{outbound}"
token = signing.dumps(
{
"u": request.user.id,
"s": base["service"],
"i": base["identifier"],
"p": str(base["person"].id) if base["person"] else "",
"outbound": outbound,
"exp": int(time.time()) + (60 * 10),
},
salt=COMPOSE_ENGAGE_TOKEN_SALT,
)
return JsonResponse(
{
"ok": True,
"preview": preview,
"outbound": outbound,
"token": token,
"artifact": artifact_label,
}
)
class ComposeEngageSend(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)
if not identifier and person is None:
return JsonResponse({"ok": False, "error": "Missing contact identifier."})
failsafe_arm = str(request.POST.get("failsafe_arm") or "").strip()
failsafe_confirm = str(request.POST.get("failsafe_confirm") or "").strip()
if failsafe_arm != "1" or failsafe_confirm != "1":
return JsonResponse(
{"ok": False, "error": "Enable both send safety switches first."}
)
token = str(request.POST.get("engage_token") or "").strip()
if not token:
return JsonResponse({"ok": False, "error": "Missing engage token."})
try:
payload = signing.loads(token, salt=COMPOSE_ENGAGE_TOKEN_SALT)
except Exception:
return JsonResponse({"ok": False, "error": "Invalid engage token."})
if int(payload.get("u") or 0) != int(request.user.id):
return JsonResponse({"ok": False, "error": "Token does not match user."})
if int(payload.get("exp") or 0) < int(time.time()):
return JsonResponse({"ok": False, "error": "Engage token expired."})
outbound = str(payload.get("outbound") or "").strip()
if not outbound:
return JsonResponse({"ok": False, "error": "Empty engage payload."})
base = _context_base(request.user, service, identifier, person)
ts = async_to_sync(transport.send_message_raw)(
base["service"],
base["identifier"],
text=outbound,
attachments=[],
)
if not ts:
return JsonResponse({"ok": False, "error": "Send failed."})
if base["person_identifier"] is not None:
session, _ = ChatSession.objects.get_or_create(
user=request.user,
identifier=base["person_identifier"],
)
ts_value = int(ts) if str(ts).isdigit() else int(time.time() * 1000)
Message.objects.create(
user=request.user,
session=session,
sender_uuid="",
text=outbound,
ts=ts_value,
delivered_ts=ts_value if str(ts).isdigit() else None,
custom_author="USER",
)
return JsonResponse({"ok": True, "message": "Shared engage sent."})
class ComposeSend(LoginRequiredMixin, View): class ComposeSend(LoginRequiredMixin, View):
def post(self, request): def post(self, request):
service = _default_service(request.POST.get("service")) service = _default_service(request.POST.get("service"))
@@ -320,6 +807,18 @@ class ComposeSend(LoginRequiredMixin, View):
if not identifier and person is None: if not identifier and person is None:
return HttpResponseBadRequest("Missing contact identifier.") return HttpResponseBadRequest("Missing contact identifier.")
failsafe_arm = str(request.POST.get("failsafe_arm") or "").strip()
failsafe_confirm = str(request.POST.get("failsafe_confirm") or "").strip()
if failsafe_arm != "1" or failsafe_confirm != "1":
return render(
request,
"partials/compose-send-status.html",
{
"notice_message": "Enable both send safety switches before sending.",
"notice_level": "warning",
},
)
text = str(request.POST.get("text") or "").strip() text = str(request.POST.get("text") or "").strip()
if not text: if not text:
return render( return render(

View File

@@ -1,15 +1,16 @@
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import GroupForm from core.forms import GroupForm
from core.models import Group from core.models import Group
from core.views.osint import OSINTListBase
from core.util import logs from core.util import logs
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
class GroupList(LoginRequiredMixin, ObjectList): class GroupList(LoginRequiredMixin, OSINTListBase):
list_template = "partials/group-list.html" osint_scope = "groups"
model = Group model = Group
page_title = "Groups" page_title = "Groups"

View File

@@ -1,15 +1,16 @@
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import ManipulationForm from core.forms import ManipulationForm
from core.models import Manipulation from core.models import Manipulation
from core.views.osint import OSINTListBase
from core.util import logs from core.util import logs
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
class ManipulationList(LoginRequiredMixin, ObjectList): class ManipulationList(LoginRequiredMixin, OSINTListBase):
list_template = "partials/manipulation-list.html" osint_scope = "manipulations"
model = Manipulation model = Manipulation
page_title = "Manipulations" page_title = "Manipulations"

1013
core/views/osint.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,18 @@
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import PersonForm from core.forms import PersonForm
from core.models import Person from core.models import Person
from core.views.osint import OSINTListBase
from core.util import logs from core.util import logs
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
class PersonList(LoginRequiredMixin, ObjectList): class PersonList(LoginRequiredMixin, OSINTListBase):
list_template = "partials/person-list.html" osint_scope = "people"
model = Person model = Person
page_title = "People" page_title = "People"
# page_subtitle = "Add times here in order to permit trading."
list_url_name = "people" list_url_name = "people"
list_url_args = ["type"] list_url_args = ["type"]

View File

@@ -1,15 +1,16 @@
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import PersonaForm from core.forms import PersonaForm
from core.models import Persona from core.models import Persona
from core.views.osint import OSINTListBase
from core.util import logs from core.util import logs
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
class PersonaList(LoginRequiredMixin, ObjectList): class PersonaList(LoginRequiredMixin, OSINTListBase):
list_template = "partials/persona-list.html" osint_scope = "personas"
model = Persona model = Persona
page_title = "Personas" page_title = "Personas"

View File

@@ -1,29 +1,94 @@
from django.conf import settings
from django.shortcuts import render
from django.views import View
from mixins.views import ObjectList, ObjectRead
from core.clients import transport from core.clients import transport
from core.views.signal import Signal, SignalAccountAdd, SignalAccounts from core.views.manage.permissions import SuperUserRequiredMixin
class WhatsApp(Signal): class WhatsApp(SuperUserRequiredMixin, View):
template_name = "pages/signal.html"
service = "whatsapp" service = "whatsapp"
page_title = "WhatsApp" page_title = "WhatsApp"
accounts_url_name = "whatsapp_accounts" accounts_url_name = "whatsapp_accounts"
def get(self, request):
return render(
request,
self.template_name,
{
"service": self.service,
"service_label": self.page_title,
"accounts_url_name": self.accounts_url_name,
},
)
class WhatsAppAccounts(SignalAccounts):
class WhatsAppAccounts(SuperUserRequiredMixin, ObjectList):
list_template = "partials/signal-accounts.html"
service = "whatsapp" service = "whatsapp"
context_object_name_singular = "WhatsApp Account" context_object_name_singular = "WhatsApp Account"
context_object_name = "WhatsApp Accounts" context_object_name = "WhatsApp Accounts"
list_url_name = "whatsapp_accounts" list_url_name = "whatsapp_accounts"
list_url_args = ["type"]
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
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
self.extra_context = self._service_context( self.extra_context = {
service="whatsapp", "service": "whatsapp",
label="WhatsApp", "service_label": "WhatsApp",
add_url_name="whatsapp_account_add", "account_add_url_name": "whatsapp_account_add",
show_contact_actions=False, "show_contact_actions": False,
) "endpoint_base": str(
getattr(settings, "WHATSAPP_HTTP_URL", "http://whatsapp:8080")
).rstrip("/"),
"service_warning": transport.get_service_warning("whatsapp"),
}
return self._normalize_accounts(transport.list_accounts("whatsapp")) return self._normalize_accounts(transport.list_accounts("whatsapp"))
class WhatsAppAccountAdd(SignalAccountAdd): class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
detail_template = "partials/whatsapp-account-add.html"
service = "whatsapp" service = "whatsapp"
context_object_name_singular = "Add Account"
context_object_name = "Add Account"
detail_url_name = "whatsapp_account_add" detail_url_name = "whatsapp_account_add"
detail_url_args = ["type", "device"]
def post(self, request, *args, **kwargs):
self.request = request
return super().get(request, *args, **kwargs)
def get_object(self, **kwargs):
form_args = self.request.POST.dict()
device_name = form_args.get("device", "GIA Device")
try:
image_bytes = transport.get_link_qr(self.service, device_name)
return {
"ok": True,
"image_b64": transport.image_bytes_to_base64(image_bytes),
"warning": transport.get_service_warning(self.service),
}
except Exception as exc:
return {
"ok": False,
"error": str(exc),
"warning": transport.get_service_warning(self.service),
}

View File

@@ -155,6 +155,55 @@ INSIGHT_METRICS = {
"values often precede misunderstandings or withdrawal cycles." "values often precede misunderstandings or withdrawal cycles."
), ),
}, },
"reciprocity_score": {
"title": "Reciprocity Component",
"group": "stability",
"history_field": "reciprocity_score",
"calculation": (
"100 * (1 - |inbound - outbound| / total_messages). Higher means "
"more balanced participation."
),
"psychology": (
"Lower reciprocity can reflect perceived asymmetry and rising pursuit/"
"withdraw cycles."
),
},
"continuity_score": {
"title": "Continuity Component",
"group": "stability",
"history_field": "continuity_score",
"calculation": (
"100 * min(1, distinct_sample_days / span_days). Higher means steadier "
"day-to-day continuity."
),
"psychology": (
"Drops can signal communication becoming episodic or reactive."
),
},
"response_score": {
"title": "Response Component",
"group": "stability",
"history_field": "response_score",
"calculation": (
"Average of inbound and outbound response-lag scores, each mapped from "
"median lag to a 0-100 curve."
),
"psychology": (
"Lower response score can indicate delayed repair loops during tension."
),
},
"volatility_score": {
"title": "Volatility Component",
"group": "stability",
"history_field": "volatility_score",
"calculation": (
"Derived from coefficient of variation of daily message counts and "
"inverted to a 0-100 stability signal."
),
"psychology": (
"High volatility can suggest inconsistent rhythm and reduced predictability."
),
},
"stability_confidence": { "stability_confidence": {
"title": "Stability Confidence", "title": "Stability Confidence",
"group": "confidence", "group": "confidence",
@@ -219,6 +268,52 @@ INSIGHT_METRICS = {
"Estimates user follow-through and consistency toward the counterpart." "Estimates user follow-through and consistency toward the counterpart."
), ),
}, },
"inbound_response_score": {
"title": "Inbound Response Score",
"group": "commitment",
"history_field": "inbound_response_score",
"calculation": (
"Response-speed score built from median lag between user outbound and "
"counterpart inbound replies."
),
"psychology": (
"Lower values suggest delayed reciprocity from counterpart direction."
),
},
"outbound_response_score": {
"title": "Outbound Response Score",
"group": "commitment",
"history_field": "outbound_response_score",
"calculation": (
"Response-speed score built from median lag between counterpart inbound "
"and user outbound replies."
),
"psychology": "Lower values suggest slower follow-through from user direction.",
},
"balance_inbound_score": {
"title": "Inbound Balance Score",
"group": "commitment",
"history_field": "balance_inbound_score",
"calculation": (
"100 * min(1, inbound_messages / outbound_messages). Captures inbound "
"participation parity."
),
"psychology": (
"Lower values can indicate one-sided conversational load from user side."
),
},
"balance_outbound_score": {
"title": "Outbound Balance Score",
"group": "commitment",
"history_field": "balance_outbound_score",
"calculation": (
"100 * min(1, outbound_messages / inbound_messages). Captures outbound "
"participation parity."
),
"psychology": (
"Lower values can indicate one-sided conversational load from counterpart side."
),
},
"commitment_confidence": { "commitment_confidence": {
"title": "Commit Confidence", "title": "Commit Confidence",
"group": "confidence", "group": "confidence",
@@ -334,6 +429,78 @@ INSIGHT_GRAPH_SPECS = [
"y_min": 0, "y_min": 0,
"y_max": None, "y_max": None,
}, },
{
"slug": "reciprocity_score",
"title": "Reciprocity Component",
"field": "reciprocity_score",
"group": "stability",
"y_min": 0,
"y_max": 100,
},
{
"slug": "continuity_score",
"title": "Continuity Component",
"field": "continuity_score",
"group": "stability",
"y_min": 0,
"y_max": 100,
},
{
"slug": "response_score",
"title": "Response Component",
"field": "response_score",
"group": "stability",
"y_min": 0,
"y_max": 100,
},
{
"slug": "volatility_score",
"title": "Volatility Component",
"field": "volatility_score",
"group": "stability",
"y_min": 0,
"y_max": 100,
},
{
"slug": "inbound_response_score",
"title": "Inbound Response Score",
"field": "inbound_response_score",
"group": "commitment",
"y_min": 0,
"y_max": 100,
},
{
"slug": "outbound_response_score",
"title": "Outbound Response Score",
"field": "outbound_response_score",
"group": "commitment",
"y_min": 0,
"y_max": 100,
},
{
"slug": "balance_inbound_score",
"title": "Inbound Balance Score",
"field": "balance_inbound_score",
"group": "commitment",
"y_min": 0,
"y_max": 100,
},
{
"slug": "balance_outbound_score",
"title": "Outbound Balance Score",
"field": "balance_outbound_score",
"group": "commitment",
"y_min": 0,
"y_max": 100,
},
{
"slug": "last_event",
"title": "Last Event Timestamp",
"field": "source_event_ts",
"group": "timeline",
"y_min": None,
"y_max": None,
},
] ]
@@ -487,7 +654,10 @@ def _to_float(value):
return float(value) return float(value)
def _format_metric_value(conversation, metric_slug): def _format_metric_value(conversation, metric_slug, latest_snapshot=None):
snapshot = latest_snapshot
if snapshot is None:
snapshot = conversation.metric_snapshots.first()
if metric_slug == "platform": if metric_slug == "platform":
return conversation.get_platform_type_display() or "-" return conversation.get_platform_type_display() or "-"
if metric_slug == "thread": if metric_slug == "thread":
@@ -498,6 +668,14 @@ def _format_metric_value(conversation, metric_slug):
return conversation.get_stability_state_display() return conversation.get_stability_state_display()
if metric_slug == "stability_score": if metric_slug == "stability_score":
return conversation.stability_score return conversation.stability_score
if metric_slug == "reciprocity_score":
return snapshot.reciprocity_score if snapshot else None
if metric_slug == "continuity_score":
return snapshot.continuity_score if snapshot else None
if metric_slug == "response_score":
return snapshot.response_score if snapshot else None
if metric_slug == "volatility_score":
return snapshot.volatility_score if snapshot else None
if metric_slug == "stability_confidence": if metric_slug == "stability_confidence":
return conversation.stability_confidence return conversation.stability_confidence
if metric_slug == "sample_messages": if metric_slug == "sample_messages":
@@ -510,6 +688,14 @@ def _format_metric_value(conversation, metric_slug):
return conversation.commitment_inbound_score return conversation.commitment_inbound_score
if metric_slug == "commitment_outbound": if metric_slug == "commitment_outbound":
return conversation.commitment_outbound_score return conversation.commitment_outbound_score
if metric_slug == "inbound_response_score":
return snapshot.inbound_response_score if snapshot else None
if metric_slug == "outbound_response_score":
return snapshot.outbound_response_score if snapshot else None
if metric_slug == "balance_inbound_score":
return snapshot.balance_inbound_score if snapshot else None
if metric_slug == "balance_outbound_score":
return snapshot.balance_outbound_score if snapshot else None
if metric_slug == "commitment_confidence": if metric_slug == "commitment_confidence":
return conversation.commitment_confidence return conversation.commitment_confidence
if metric_slug == "commitment_computed": if metric_slug == "commitment_computed":
@@ -2713,7 +2899,8 @@ class AIWorkspaceInsightDetail(LoginRequiredMixin, View):
person = get_object_or_404(Person, pk=person_id, user=request.user) person = get_object_or_404(Person, pk=person_id, user=request.user)
conversation = _conversation_for_person(request.user, person) conversation = _conversation_for_person(request.user, person)
value = _format_metric_value(conversation, metric) latest_snapshot = conversation.metric_snapshots.first()
value = _format_metric_value(conversation, metric, latest_snapshot)
group = INSIGHT_GROUPS[spec["group"]] group = INSIGHT_GROUPS[spec["group"]]
points = [] points = []
if spec["history_field"]: if spec["history_field"]:
@@ -2773,6 +2960,7 @@ class AIWorkspaceInsightHelp(LoginRequiredMixin, View):
person = get_object_or_404(Person, pk=person_id, user=request.user) person = get_object_or_404(Person, pk=person_id, user=request.user)
conversation = _conversation_for_person(request.user, person) conversation = _conversation_for_person(request.user, person)
latest_snapshot = conversation.metric_snapshots.first()
metrics = [] metrics = []
for slug, spec in INSIGHT_METRICS.items(): for slug, spec in INSIGHT_METRICS.items():
metrics.append( metrics.append(
@@ -2783,7 +2971,11 @@ class AIWorkspaceInsightHelp(LoginRequiredMixin, View):
"group_title": INSIGHT_GROUPS[spec["group"]]["title"], "group_title": INSIGHT_GROUPS[spec["group"]]["title"],
"calculation": spec["calculation"], "calculation": spec["calculation"],
"psychology": spec["psychology"], "psychology": spec["psychology"],
"value": _format_metric_value(conversation, slug), "value": _format_metric_value(
conversation,
slug,
latest_snapshot,
),
} }
) )