Implement business plans

This commit is contained in:
2026-03-02 00:00:53 +00:00
parent d22924f6aa
commit b3e183eb0a
26 changed files with 4109 additions and 39 deletions

View File

@@ -22,6 +22,7 @@ from two_factor.urls import urlpatterns as tf_urls
from core.views import ( from core.views import (
ais, ais,
automation,
base, base,
compose, compose,
groups, groups,
@@ -60,6 +61,21 @@ urlpatterns = [
system.SystemSettings.as_view(), system.SystemSettings.as_view(),
name="system_settings", name="system_settings",
), ),
path(
"settings/command-routing/",
automation.CommandRoutingSettings.as_view(),
name="command_routing",
),
path(
"settings/business-plan/<str:doc_id>/",
automation.BusinessPlanEditor.as_view(),
name="business_plan_editor",
),
path(
"settings/translation/preview/",
automation.TranslationPreview.as_view(),
name="translation_preview",
),
path( path(
"services/signal/", "services/signal/",
signal.Signal.as_view(), signal.Signal.as_view(),
@@ -205,6 +221,16 @@ urlpatterns = [
compose.ComposeHistorySync.as_view(), compose.ComposeHistorySync.as_view(),
name="compose_history_sync", name="compose_history_sync",
), ),
path(
"compose/commands/bp/bind/",
compose.ComposeBindBP.as_view(),
name="compose_bind_bp",
),
path(
"compose/commands/toggle/",
compose.ComposeToggleCommand.as_view(),
name="compose_toggle_command",
),
path( path(
"compose/media/blob/", "compose/media/blob/",
compose.ComposeMediaBlob.as_view(), compose.ComposeMediaBlob.as_view(),

View File

@@ -11,7 +11,7 @@ from django.urls import reverse
from signalbot import Command, Context, SignalBot from signalbot import Command, Context, SignalBot
from core.clients import ClientBase, signalapi from core.clients import ClientBase, signalapi
from core.messaging import ai, history, media_bridge, natural, replies, utils from core.messaging import ai, history, media_bridge, natural, replies, reply_sync, utils
from core.models import Chat, Manipulation, PersonIdentifier, PlatformChatLink, QueuedMessage from core.models import Chat, Manipulation, PersonIdentifier, PlatformChatLink, QueuedMessage
from core.util import logs from core.util import logs
@@ -358,6 +358,13 @@ class HandleMessage(Command):
ts = c.message.timestamp ts = c.message.timestamp
source_value = c.message.source source_value = c.message.source
envelope = raw.get("envelope", {}) envelope = raw.get("envelope", {})
signal_source_message_id = str(
envelope.get("serverGuid")
or envelope.get("guid")
or envelope.get("timestamp")
or c.message.timestamp
or ""
).strip()
destination_number = sent_message.get("destination") destination_number = sent_message.get("destination")
bot_uuid = str(getattr(c.bot, "bot_uuid", "") or "").strip() bot_uuid = str(getattr(c.bot, "bot_uuid", "") or "").strip()
@@ -639,16 +646,36 @@ class HandleMessage(Command):
identifier.user, identifier identifier.user, identifier
) )
session_cache[session_key] = chat_session session_cache[session_key] = chat_session
reply_ref = reply_sync.extract_reply_ref(self.service, raw)
reply_target = await reply_sync.resolve_reply_target(
identifier.user,
chat_session,
reply_ref,
)
sender_key = source_uuid or source_number or identifier_candidates[0] sender_key = source_uuid or source_number or identifier_candidates[0]
message_key = (chat_session.id, ts, sender_key) message_key = (chat_session.id, ts, sender_key)
message_text = identifier_text_overrides.get(session_key, relay_text) message_text = identifier_text_overrides.get(session_key, relay_text)
if message_key not in stored_messages: if message_key not in stored_messages:
await history.store_message( origin_tag = reply_sync.extract_origin_tag(raw)
local_message = await history.store_message(
session=chat_session, session=chat_session,
sender=sender_key, sender=sender_key,
text=message_text, text=message_text,
ts=ts, ts=ts,
outgoing=is_from_bot, outgoing=is_from_bot,
source_service=self.service,
source_message_id=signal_source_message_id,
source_chat_id=str(
destination_number_norm or dest_norm or sender_key or ""
),
reply_to=reply_target,
reply_source_service=str(
reply_ref.get("reply_source_service") or ""
),
reply_source_message_id=str(
reply_ref.get("reply_source_message_id") or ""
),
message_meta=reply_sync.apply_sync_origin({}, origin_tag),
) )
stored_messages.add(message_key) stored_messages.add(message_key)
# Notify unified router to ensure service context is preserved # Notify unified router to ensure service context is preserved
@@ -658,6 +685,7 @@ class HandleMessage(Command):
text=message_text, text=message_text,
ts=ts, ts=ts,
payload=msg, payload=msg,
local_message=local_message,
) )
# TODO: Permission checks # TODO: Permission checks

View File

@@ -1,5 +1,6 @@
import asyncio import asyncio
import inspect import inspect
import json
import logging import logging
import mimetypes import mimetypes
import os import os
@@ -14,9 +15,14 @@ from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from core.clients import ClientBase, transport from core.clients import ClientBase, transport
from core.messaging import history, media_bridge from core.messaging import history, media_bridge, reply_sync
from core.models import Message, PersonIdentifier, PlatformChatLink from core.models import Message, PersonIdentifier, PlatformChatLink
try:
from google.protobuf.json_format import MessageToDict
except Exception: # pragma: no cover
MessageToDict = None
class WhatsAppClient(ClientBase): class WhatsAppClient(ClientBase):
""" """
@@ -45,6 +51,9 @@ class WhatsAppClient(ClientBase):
self._qr_handler_supported = False self._qr_handler_supported = False
self._event_hook_callable = False self._event_hook_callable = False
self._last_send_error = "" self._last_send_error = ""
self.reply_debug_chat = str(
getattr(settings, "WHATSAPP_REPLY_DEBUG_CHAT", "120363402761690215")
).strip()
self.enabled = bool( self.enabled = bool(
str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower() str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower()
@@ -1464,6 +1473,72 @@ class WhatsAppClient(ClientBase):
return sorted(str(key) for key in vars(obj).keys()) return sorted(str(key) for key in vars(obj).keys())
return [] return []
def _proto_to_dict(self, obj):
if obj is None:
return {}
if isinstance(obj, dict):
return obj
# Neonize emits protobuf objects for inbound events. Convert them to a
# plain dict so nested contextInfo reply fields are addressable.
if MessageToDict is not None and hasattr(obj, "DESCRIPTOR"):
try:
return MessageToDict(
obj,
preserving_proto_field_name=True,
use_integers_for_enums=True,
)
except Exception:
pass
return {}
def _chat_matches_reply_debug(self, chat: str) -> bool:
target = str(self.reply_debug_chat or "").strip()
value = str(chat or "").strip()
if not target or not value:
return False
value_local = value.split("@", 1)[0]
return value == target or value_local == target
def _extract_reply_hints(self, obj, max_depth: int = 6):
hints = []
def walk(value, path="", depth=0):
if depth > max_depth or value is None:
return
if isinstance(value, dict):
for key, nested in value.items():
key_str = str(key)
next_path = f"{path}.{key_str}" if path else key_str
lowered = key_str.lower()
if any(
token in lowered
for token in ("stanza", "quoted", "reply", "context")
):
if isinstance(nested, (str, int, float, bool)):
hints.append(
{
"path": next_path,
"value": str(nested)[:180],
}
)
walk(nested, next_path, depth + 1)
return
if isinstance(value, list):
for idx, nested in enumerate(value):
walk(nested, f"{path}[{idx}]", depth + 1)
walk(obj, "", 0)
# Deduplicate by path/value for compact diagnostics.
unique = []
seen = set()
for row in hints:
key = (str(row.get("path") or ""), str(row.get("value") or ""))
if key in seen:
continue
seen.add(key)
unique.append(row)
return unique[:40]
def _normalize_timestamp(self, raw_value): def _normalize_timestamp(self, raw_value):
if raw_value is None: if raw_value is None:
return int(time.time() * 1000) return int(time.time() * 1000)
@@ -2361,19 +2436,22 @@ class WhatsAppClient(ClientBase):
] ]
async def _handle_message_event(self, event): async def _handle_message_event(self, event):
msg_obj = self._pluck(event, "message") or self._pluck(event, "Message") event_obj = self._proto_to_dict(event) or event
text = self._message_text(msg_obj, event) msg_obj = self._pluck(event_obj, "message") or self._pluck(event_obj, "Message")
text = self._message_text(msg_obj, event_obj)
if not text: if not text:
self.log.debug( self.log.debug(
"whatsapp empty-text event shape: msg_keys=%s event_keys=%s type=%s", "whatsapp empty-text event shape: msg_keys=%s event_keys=%s type=%s",
self._shape_keys(msg_obj), self._shape_keys(msg_obj),
self._shape_keys(event), self._shape_keys(event_obj),
str(type(event).__name__), str(type(event).__name__),
) )
source = ( source = (
self._pluck(event, "Info", "MessageSource") self._pluck(event_obj, "Info", "MessageSource")
or self._pluck(event, "info", "message_source") or self._pluck(event_obj, "info", "message_source")
or self._pluck(event, "info", "messageSource") or self._pluck(event_obj, "info", "messageSource")
or self._pluck(event_obj, "info", "message_source")
or self._pluck(event_obj, "info", "messageSource")
) )
is_from_me = bool( is_from_me = bool(
self._pluck(source, "IsFromMe") or self._pluck(source, "isFromMe") self._pluck(source, "IsFromMe") or self._pluck(source, "isFromMe")
@@ -2389,17 +2467,17 @@ class WhatsAppClient(ClientBase):
self._pluck(source, "Chat") or self._pluck(source, "chat") self._pluck(source, "Chat") or self._pluck(source, "chat")
) )
raw_ts = ( raw_ts = (
self._pluck(event, "Info", "Timestamp") self._pluck(event_obj, "Info", "Timestamp")
or self._pluck(event, "info", "timestamp") or self._pluck(event_obj, "info", "timestamp")
or self._pluck(event, "info", "message_timestamp") or self._pluck(event_obj, "info", "message_timestamp")
or self._pluck(event, "Timestamp") or self._pluck(event_obj, "Timestamp")
or self._pluck(event, "timestamp") or self._pluck(event_obj, "timestamp")
) )
msg_id = str( msg_id = str(
self._pluck(event, "Info", "ID") self._pluck(event_obj, "Info", "ID")
or self._pluck(event, "info", "id") or self._pluck(event_obj, "info", "id")
or self._pluck(event, "ID") or self._pluck(event_obj, "ID")
or self._pluck(event, "id") or self._pluck(event_obj, "id")
or "" or ""
).strip() ).strip()
ts = self._normalize_timestamp(raw_ts) ts = self._normalize_timestamp(raw_ts)
@@ -2529,12 +2607,196 @@ class WhatsAppClient(ClientBase):
)() )()
if duplicate_exists: if duplicate_exists:
continue continue
await history.store_message( reply_ref = reply_sync.extract_reply_ref(
self.service,
{
"contextInfo": self._pluck(msg_obj, "contextInfo")
or self._pluck(msg_obj, "ContextInfo")
or self._pluck(msg_obj, "extendedTextMessage", "contextInfo")
or self._pluck(msg_obj, "ExtendedTextMessage", "ContextInfo")
or self._pluck(msg_obj, "imageMessage", "contextInfo")
or self._pluck(msg_obj, "ImageMessage", "ContextInfo")
or self._pluck(msg_obj, "videoMessage", "contextInfo")
or self._pluck(msg_obj, "VideoMessage", "ContextInfo")
or self._pluck(
msg_obj,
"ephemeralMessage",
"message",
"extendedTextMessage",
"contextInfo",
)
or self._pluck(
msg_obj,
"EphemeralMessage",
"Message",
"ExtendedTextMessage",
"ContextInfo",
)
or self._pluck(
msg_obj,
"viewOnceMessage",
"message",
"extendedTextMessage",
"contextInfo",
)
or self._pluck(
msg_obj,
"ViewOnceMessage",
"Message",
"ExtendedTextMessage",
"ContextInfo",
)
or self._pluck(
msg_obj,
"viewOnceMessageV2",
"message",
"extendedTextMessage",
"contextInfo",
)
or self._pluck(
msg_obj,
"ViewOnceMessageV2",
"Message",
"ExtendedTextMessage",
"ContextInfo",
)
or self._pluck(
msg_obj,
"viewOnceMessageV2Extension",
"message",
"extendedTextMessage",
"contextInfo",
)
or self._pluck(
msg_obj,
"ViewOnceMessageV2Extension",
"Message",
"ExtendedTextMessage",
"ContextInfo",
)
or {},
"messageContextInfo": self._pluck(msg_obj, "messageContextInfo")
or self._pluck(msg_obj, "MessageContextInfo")
or {},
"message": {
"extendedTextMessage": self._pluck(msg_obj, "extendedTextMessage")
or self._pluck(msg_obj, "ExtendedTextMessage")
or {},
"imageMessage": self._pluck(msg_obj, "imageMessage") or {},
"ImageMessage": self._pluck(msg_obj, "ImageMessage") or {},
"videoMessage": self._pluck(msg_obj, "videoMessage") or {},
"VideoMessage": self._pluck(msg_obj, "VideoMessage") or {},
"documentMessage": self._pluck(msg_obj, "documentMessage")
or {},
"DocumentMessage": self._pluck(msg_obj, "DocumentMessage")
or {},
"ephemeralMessage": self._pluck(msg_obj, "ephemeralMessage")
or {},
"EphemeralMessage": self._pluck(msg_obj, "EphemeralMessage")
or {},
"viewOnceMessage": self._pluck(msg_obj, "viewOnceMessage")
or {},
"ViewOnceMessage": self._pluck(msg_obj, "ViewOnceMessage")
or {},
"viewOnceMessageV2": self._pluck(msg_obj, "viewOnceMessageV2")
or {},
"ViewOnceMessageV2": self._pluck(msg_obj, "ViewOnceMessageV2")
or {},
"viewOnceMessageV2Extension": self._pluck(
msg_obj, "viewOnceMessageV2Extension"
)
or {},
"ViewOnceMessageV2Extension": self._pluck(
msg_obj, "ViewOnceMessageV2Extension"
)
or {},
},
},
)
reply_debug = {}
if self._chat_matches_reply_debug(chat):
reply_debug = reply_sync.extract_whatsapp_reply_debug(
{
"contextInfo": self._pluck(msg_obj, "contextInfo") or {},
"messageContextInfo": self._pluck(msg_obj, "messageContextInfo")
or {},
"message": {
"extendedTextMessage": self._pluck(
msg_obj, "extendedTextMessage"
)
or {},
"imageMessage": self._pluck(msg_obj, "imageMessage") or {},
"videoMessage": self._pluck(msg_obj, "videoMessage") or {},
"documentMessage": self._pluck(msg_obj, "documentMessage")
or {},
"ephemeralMessage": self._pluck(msg_obj, "ephemeralMessage")
or {},
"viewOnceMessage": self._pluck(msg_obj, "viewOnceMessage")
or {},
"viewOnceMessageV2": self._pluck(msg_obj, "viewOnceMessageV2")
or {},
"viewOnceMessageV2Extension": self._pluck(
msg_obj, "viewOnceMessageV2Extension"
)
or {},
},
}
)
self.log.warning(
"wa-reply-debug chat=%s msg_id=%s reply_ref=%s debug=%s",
str(chat or ""),
str(msg_id or ""),
json.dumps(reply_ref, ensure_ascii=True),
json.dumps(reply_debug, ensure_ascii=True),
)
reply_target = await reply_sync.resolve_reply_target(
identifier.user,
session,
reply_ref,
)
message_meta = reply_sync.apply_sync_origin(
{},
reply_sync.extract_origin_tag(payload),
)
if self._chat_matches_reply_debug(chat):
info_obj = self._proto_to_dict(self._pluck(event_obj, "Info")) or self._pluck(
event_obj, "Info"
)
raw_obj = self._proto_to_dict(self._pluck(event_obj, "Raw")) or self._pluck(
event_obj, "Raw"
)
message_meta["wa_reply_debug"] = {
"reply_ref": reply_ref,
"reply_target_id": str(getattr(reply_target, "id", "") or ""),
"msg_id": str(msg_id or ""),
"chat": str(chat or ""),
"sender": str(sender or ""),
"msg_obj_keys": self._shape_keys(msg_obj),
"event_keys": self._shape_keys(event_obj),
"info_keys": self._shape_keys(info_obj),
"raw_keys": self._shape_keys(raw_obj),
"event_type": str(type(event).__name__),
"reply_hints_event": self._extract_reply_hints(event_obj),
"reply_hints_message": self._extract_reply_hints(msg_obj),
"reply_hints_info": self._extract_reply_hints(info_obj),
"reply_hints_raw": self._extract_reply_hints(raw_obj),
"debug": reply_debug,
}
local_message = await history.store_message(
session=session, session=session,
sender=str(sender or chat or ""), sender=str(sender or chat or ""),
text=display_text, text=display_text,
ts=ts, ts=ts,
outgoing=is_from_me, outgoing=is_from_me,
source_service=self.service,
source_message_id=str(msg_id or ""),
source_chat_id=str(chat or sender or ""),
reply_to=reply_target,
reply_source_service=str(reply_ref.get("reply_source_service") or ""),
reply_source_message_id=str(
reply_ref.get("reply_source_message_id") or ""
),
message_meta=message_meta,
) )
await self.ur.message_received( await self.ur.message_received(
self.service, self.service,
@@ -2542,6 +2804,7 @@ class WhatsAppClient(ClientBase):
text=display_text, text=display_text,
ts=ts, ts=ts,
payload=payload, payload=payload,
local_message=local_message,
) )
async def _handle_receipt_event(self, event): async def _handle_receipt_event(self, event):
@@ -2679,6 +2942,11 @@ class WhatsAppClient(ClientBase):
return "" return ""
if "@" in raw: if "@" in raw:
return raw return raw
# Group chats often arrive as bare numeric ids in compose/runtime
# payloads; prefer known group mappings before defaulting to person JIDs.
group_jid = self._resolve_group_jid(raw)
if group_jid:
return group_jid
digits = re.sub(r"[^0-9]", "", raw) digits = re.sub(r"[^0-9]", "", raw)
if digits: if digits:
# Prefer direct JID formatting for phone numbers; Neonize build_jid # Prefer direct JID formatting for phone numbers; Neonize build_jid
@@ -2691,6 +2959,66 @@ class WhatsAppClient(ClientBase):
pass pass
return raw return raw
def _resolve_group_jid(self, value: str) -> str:
local = str(value or "").strip().split("@", 1)[0].strip()
if not local:
return ""
# Runtime state is the cheapest source of truth for currently joined groups.
state = transport.get_runtime_state(self.service) or {}
for row in list(state.get("groups") or []):
if not isinstance(row, dict):
continue
candidates = (
row.get("identifier"),
row.get("chat_identifier"),
row.get("chat"),
row.get("jid"),
row.get("chat_jid"),
)
matched = False
for candidate in candidates:
candidate_local = str(self._jid_to_identifier(candidate) or "").split(
"@", 1
)[0].strip()
if candidate_local and candidate_local == local:
matched = True
break
if not matched:
continue
jid = str(
self._jid_to_identifier(row.get("jid") or row.get("chat_jid") or "")
).strip()
if jid and "@g.us" in jid:
return jid
return f"{local}@g.us"
# DB fallback for compose pages that resolved from PlatformChatLink.
try:
link = (
PlatformChatLink.objects.filter(
service="whatsapp",
chat_identifier=local,
is_group=True,
)
.order_by("-updated_at", "-id")
.first()
)
except Exception:
link = None
if link is not None:
jid = str(self._jid_to_identifier(link.chat_jid or "")).strip()
if jid and "@g.us" in jid:
return jid
return f"{local}@g.us"
# WhatsApp group ids are numeric and usually very long (commonly start
# with 120...). Treat those as groups when no explicit mapping exists.
digits = re.sub(r"[^0-9]", "", local)
if digits and digits == local and len(digits) >= 15 and digits.startswith("120"):
return f"{digits}@g.us"
return ""
def _blob_key_to_compose_url(self, blob_key): def _blob_key_to_compose_url(self, blob_key):
key = str(blob_key or "").strip() key = str(blob_key or "").strip()
if not key: if not key:
@@ -2806,8 +3134,31 @@ class WhatsAppClient(ClientBase):
metadata = dict(metadata or {}) metadata = dict(metadata or {})
xmpp_source_id = str(metadata.get("xmpp_source_id") or "").strip() xmpp_source_id = str(metadata.get("xmpp_source_id") or "").strip()
legacy_message_id = str(metadata.get("legacy_message_id") or "").strip() legacy_message_id = str(metadata.get("legacy_message_id") or "").strip()
reply_to_upstream_message_id = str(
metadata.get("reply_to_upstream_message_id") or ""
).strip()
reply_to_participant = str(metadata.get("reply_to_participant") or "").strip()
reply_to_remote_jid = str(metadata.get("reply_to_remote_jid") or "").strip()
person_identifier = None person_identifier = None
if xmpp_source_id: if legacy_message_id:
person_identifier = await sync_to_async(
lambda: (
Message.objects.filter(id=legacy_message_id)
.select_related("session__identifier__user", "session__identifier__person")
.first()
)
)()
if person_identifier is not None:
person_identifier = getattr(
getattr(person_identifier, "session", None), "identifier", None
)
if (
person_identifier is not None
and str(getattr(person_identifier, "service", "") or "").strip().lower()
!= "whatsapp"
):
person_identifier = None
if person_identifier is None and (xmpp_source_id or legacy_message_id):
candidates = list(self._normalize_identifier_candidates(recipient, jid_str)) candidates = list(self._normalize_identifier_candidates(recipient, jid_str))
if candidates: if candidates:
person_identifier = await sync_to_async( person_identifier = await sync_to_async(
@@ -2828,8 +3179,25 @@ class WhatsAppClient(ClientBase):
or "" or ""
).strip() ).strip()
def _record_bridge(response, ts_value, body_hint=""): async def _record_bridge(response, ts_value, body_hint=""):
if not xmpp_source_id or person_identifier is None: if person_identifier is None:
return
upstream_message_id = _extract_response_message_id(response)
if legacy_message_id:
try:
await history.save_bridge_ref(
person_identifier.user,
person_identifier,
source_service="whatsapp",
local_message_id=legacy_message_id,
local_ts=int(ts_value or int(time.time() * 1000)),
upstream_message_id=upstream_message_id,
upstream_author=str(recipient or ""),
upstream_ts=int(ts_value or 0),
)
except Exception:
pass
if not xmpp_source_id:
return return
transport.record_bridge_mapping( transport.record_bridge_mapping(
user_id=person_identifier.user_id, user_id=person_identifier.user_id,
@@ -2837,7 +3205,7 @@ class WhatsAppClient(ClientBase):
service="whatsapp", service="whatsapp",
xmpp_message_id=xmpp_source_id, xmpp_message_id=xmpp_source_id,
xmpp_ts=int(metadata.get("xmpp_source_ts") or 0), xmpp_ts=int(metadata.get("xmpp_source_ts") or 0),
upstream_message_id=_extract_response_message_id(response), upstream_message_id=upstream_message_id,
upstream_ts=int(ts_value or 0), upstream_ts=int(ts_value or 0),
text_preview=str(body_hint or metadata.get("xmpp_body") or ""), text_preview=str(body_hint or metadata.get("xmpp_body") or ""),
local_message_id=legacy_message_id, local_message_id=legacy_message_id,
@@ -2899,7 +3267,7 @@ class WhatsAppClient(ClientBase):
sent_ts, sent_ts,
self._normalize_timestamp(self._pluck(response, "Timestamp") or 0), self._normalize_timestamp(self._pluck(response, "Timestamp") or 0),
) )
_record_bridge(response, sent_ts, body_hint=filename) await _record_bridge(response, sent_ts, body_hint=filename)
sent_any = True sent_any = True
if getattr(settings, "WHATSAPP_DEBUG", False): if getattr(settings, "WHATSAPP_DEBUG", False):
self.log.debug( self.log.debug(
@@ -2916,6 +3284,35 @@ class WhatsAppClient(ClientBase):
if text: if text:
response = None response = None
last_error = None last_error = None
quoted_text_message = text
if reply_to_upstream_message_id:
try:
from neonize.proto.waE2E.WAWebProtobufsE2E_pb2 import (
ContextInfo,
ExtendedTextMessage,
Message as WAProtoMessage,
)
context = ContextInfo(
stanzaID=reply_to_upstream_message_id,
)
participant_jid = self._to_jid(reply_to_participant)
remote_jid = self._to_jid(reply_to_remote_jid) or jid_str
if participant_jid:
context.participant = participant_jid
if remote_jid:
context.remoteJID = remote_jid
quoted_text_message = WAProtoMessage(
extendedTextMessage=ExtendedTextMessage(
text=str(text or ""),
contextInfo=context,
)
)
except Exception as exc:
self.log.warning(
"whatsapp quoted-reply payload build failed: %s", exc
)
quoted_text_message = text
# Prepare cancel key (if caller provided command_id) # Prepare cancel key (if caller provided command_id)
cancel_key = None cancel_key = None
try: try:
@@ -2945,7 +3342,7 @@ class WhatsAppClient(ClientBase):
response = await self._call_client_method( response = await self._call_client_method(
getattr(self._client, "send_message", None), getattr(self._client, "send_message", None),
send_target, send_target,
text, quoted_text_message,
timeout=9.0, timeout=9.0,
) )
sent_any = True sent_any = True
@@ -3030,7 +3427,7 @@ class WhatsAppClient(ClientBase):
sent_ts, sent_ts,
self._normalize_timestamp(self._pluck(response, "Timestamp") or 0), self._normalize_timestamp(self._pluck(response, "Timestamp") or 0),
) )
_record_bridge(response, sent_ts, body_hint=str(text or "")) await _record_bridge(response, sent_ts, body_hint=str(text or ""))
if not sent_any: if not sent_any:
self._last_send_error = "no_payload_sent" self._last_send_error = "no_payload_sent"

View File

@@ -16,7 +16,7 @@ from slixmpp.xmlstream import register_stanza_plugin
from slixmpp.xmlstream.stanzabase import ET from slixmpp.xmlstream.stanzabase import ET
from core.clients import ClientBase, transport from core.clients import ClientBase, transport
from core.messaging import ai, history, replies, utils from core.messaging import ai, history, replies, reply_sync, utils
from core.models import ( from core.models import (
ChatSession, ChatSession,
Manipulation, Manipulation,
@@ -1236,14 +1236,46 @@ class XMPPComponent(ComponentXMPP):
user=identifier.user, user=identifier.user,
) )
self.log.debug("Storing outbound XMPP message in history") self.log.debug("Storing outbound XMPP message in history")
reply_ref = reply_sync.extract_reply_ref(
"xmpp",
{
"reply_source_message_id": parsed_reply_target,
"reply_source_chat_id": str(sender_jid or ""),
},
)
reply_target = await reply_sync.resolve_reply_target(
identifier.user,
session,
reply_ref,
)
local_message = await history.store_message( local_message = await history.store_message(
session=session, session=session,
sender="XMPP", sender="XMPP",
text=body, text=body,
ts=int(now().timestamp() * 1000), ts=int(now().timestamp() * 1000),
outgoing=True, outgoing=True,
source_service="xmpp",
source_message_id=xmpp_message_id,
source_chat_id=str(sender_jid or ""),
reply_to=reply_target,
reply_source_service=str(reply_ref.get("reply_source_service") or ""),
reply_source_message_id=str(
reply_ref.get("reply_source_message_id") or ""
),
message_meta={},
) )
self.log.debug("Stored outbound XMPP message in history") self.log.debug("Stored outbound XMPP message in history")
await self.ur.message_received(
"xmpp",
identifier=identifier,
text=body,
ts=int(now().timestamp() * 1000),
payload={
"sender_jid": sender_jid,
"recipient_jid": recipient_jid,
},
local_message=local_message,
)
manipulations = Manipulation.objects.filter( manipulations = Manipulation.objects.filter(
group__people=identifier.person, group__people=identifier.person,

View File

29
core/commands/base.py Normal file
View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(slots=True)
class CommandContext:
service: str
channel_identifier: str
message_id: str
user_id: int
message_text: str
payload: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class CommandResult:
ok: bool
status: str = "ok"
error: str = ""
payload: dict[str, Any] = field(default_factory=dict)
class CommandHandler:
slug = ""
async def execute(self, ctx: CommandContext) -> CommandResult:
raise NotImplementedError

125
core/commands/engine.py Normal file
View File

@@ -0,0 +1,125 @@
from __future__ import annotations
from asgiref.sync import sync_to_async
from core.commands.base import CommandContext, CommandResult
from core.commands.handlers.bp import BPCommandHandler
from core.commands.registry import get as get_handler
from core.commands.registry import register
from core.messaging.reply_sync import is_mirrored_origin
from core.models import CommandChannelBinding, CommandProfile, Message
from core.util import logs
log = logs.get_logger("command_engine")
_REGISTERED = False
def ensure_handlers_registered():
global _REGISTERED
if _REGISTERED:
return
register(BPCommandHandler())
_REGISTERED = True
async def _eligible_profiles(ctx: CommandContext) -> list[CommandProfile]:
def _load():
direct = list(
CommandProfile.objects.filter(
user_id=ctx.user_id,
enabled=True,
channel_bindings__enabled=True,
channel_bindings__direction="ingress",
channel_bindings__service=ctx.service,
channel_bindings__channel_identifier=ctx.channel_identifier,
).distinct()
)
if direct:
return direct
# Compose-originated messages use `web` service even when the
# underlying conversation is mapped to a platform identifier.
if str(ctx.service or "").strip().lower() != "web":
return []
trigger = (
Message.objects.select_related("session", "session__identifier")
.filter(id=ctx.message_id, user_id=ctx.user_id)
.first()
)
identifier = getattr(getattr(trigger, "session", None), "identifier", None)
fallback_service = str(getattr(identifier, "service", "") or "").strip().lower()
fallback_identifier = str(getattr(identifier, "identifier", "") or "").strip()
if not fallback_service or not fallback_identifier:
return []
return list(
CommandProfile.objects.filter(
user_id=ctx.user_id,
enabled=True,
channel_bindings__enabled=True,
channel_bindings__direction="ingress",
channel_bindings__service=fallback_service,
channel_bindings__channel_identifier=fallback_identifier,
).distinct()
)
return await sync_to_async(_load)()
def _matches_trigger(profile: CommandProfile, text: str) -> bool:
body = str(text or "").strip()
trigger = str(profile.trigger_token or "").strip()
if not trigger:
return False
if profile.exact_match_only:
return body == trigger
return trigger in body
async def process_inbound_message(ctx: CommandContext) -> list[CommandResult]:
ensure_handlers_registered()
trigger_message = await sync_to_async(
lambda: Message.objects.filter(id=ctx.message_id).first()
)()
if trigger_message is None:
return []
if is_mirrored_origin(trigger_message.message_meta):
return []
profiles = await _eligible_profiles(ctx)
results: list[CommandResult] = []
for profile in profiles:
if not _matches_trigger(profile, ctx.message_text):
continue
if profile.reply_required and trigger_message.reply_to_id is None:
results.append(
CommandResult(
ok=False,
status="skipped",
error="reply_required",
payload={"profile": profile.slug},
)
)
continue
handler = get_handler(profile.slug)
if handler is None:
results.append(
CommandResult(
ok=False,
status="failed",
error=f"missing_handler:{profile.slug}",
)
)
continue
try:
result = await handler.execute(ctx)
results.append(result)
except Exception as exc:
log.exception("command execution failed for profile=%s: %s", profile.slug, exc)
results.append(
CommandResult(
ok=False,
status="failed",
error=f"handler_exception:{exc}",
)
)
return results

View File

View File

@@ -0,0 +1,358 @@
from __future__ import annotations
import time
from asgiref.sync import sync_to_async
from django.conf import settings
from core.clients import transport
from core.commands.base import CommandContext, CommandHandler, CommandResult
from core.messaging import ai as ai_runner
from core.messaging.utils import messages_to_string
from core.models import (
AI,
BusinessPlanDocument,
BusinessPlanRevision,
ChatSession,
CommandAction,
CommandChannelBinding,
CommandRun,
Message,
)
def _bp_system_prompt():
return (
"Create a structured business plan using the given template. "
"Follow the template section order exactly. "
"If data is missing, write concise assumptions and risks. "
"Return markdown only."
)
def _clamp_transcript(transcript: str, max_chars: int) -> str:
text = str(transcript or "")
if max_chars <= 0 or len(text) <= max_chars:
return text
head_size = min(2000, max_chars // 3)
tail_size = max(0, max_chars - head_size - 140)
omitted = len(text) - head_size - tail_size
return (
text[:head_size].rstrip()
+ f"\n\n[... truncated {max(0, omitted)} chars ...]\n\n"
+ text[-tail_size:].lstrip()
)
def _bp_fallback_markdown(template_text: str, transcript: str, error_text: str = "") -> str:
header = (
"## Business Plan (Draft)\n\n"
"Automatic fallback was used because AI generation failed for this run.\n"
)
if error_text:
header += f"\nFailure: `{error_text}`\n"
return (
f"{header}\n"
"### Template\n"
f"{template_text}\n\n"
"### Transcript Window\n"
f"{transcript}"
)
def _chunk_for_transport(text: str, limit: int = 3000) -> list[str]:
body = str(text or "").strip()
if not body:
return []
if len(body) <= limit:
return [body]
parts = []
remaining = body
while len(remaining) > limit:
cut = remaining.rfind("\n\n", 0, limit)
if cut < int(limit * 0.45):
cut = remaining.rfind("\n", 0, limit)
if cut < int(limit * 0.35):
cut = limit
parts.append(remaining[:cut].rstrip())
remaining = remaining[cut:].lstrip()
if remaining:
parts.append(remaining)
return [part for part in parts if part]
class BPCommandHandler(CommandHandler):
slug = "bp"
async def _status_message(self, trigger_message: Message, text: str):
service = str(trigger_message.source_service or "").strip().lower()
if service == "web":
await sync_to_async(Message.objects.create)(
user=trigger_message.user,
session=trigger_message.session,
sender_uuid="",
text=text,
ts=int(time.time() * 1000),
custom_author="BOT",
source_service="web",
source_chat_id=trigger_message.source_chat_id or "",
)
return
if service == "xmpp" and str(trigger_message.source_chat_id or "").strip():
try:
await transport.send_message_raw(
"xmpp",
str(trigger_message.source_chat_id or "").strip(),
text=text,
attachments=[],
metadata={"origin_tag": f"bp-status:{trigger_message.id}"},
)
except Exception:
return
async def _fanout(self, run: CommandRun, text: str) -> dict:
profile = run.profile
trigger = await sync_to_async(
lambda: Message.objects.select_related("session", "user")
.filter(id=run.trigger_message_id)
.first()
)()
if trigger is None:
return {"sent_bindings": 0, "failed_bindings": 0}
bindings = await sync_to_async(list)(
CommandChannelBinding.objects.filter(
profile=profile,
enabled=True,
direction="egress",
)
)
sent_bindings = 0
failed_bindings = 0
for binding in bindings:
if binding.service == "web":
session = None
channel_identifier = str(binding.channel_identifier or "").strip()
if (
channel_identifier
and channel_identifier == str(trigger.source_chat_id or "").strip()
):
session = trigger.session
if session is None and channel_identifier:
session = await sync_to_async(
lambda: ChatSession.objects.filter(
user=trigger.user,
identifier__identifier=channel_identifier,
)
.order_by("-last_interaction")
.first()
)()
if session is None:
session = trigger.session
await sync_to_async(Message.objects.create)(
user=trigger.user,
session=session,
sender_uuid="",
text=text,
ts=int(time.time() * 1000),
custom_author="BOT",
source_service="web",
source_chat_id=channel_identifier or str(trigger.source_chat_id or ""),
message_meta={"origin_tag": f"bp:{run.id}"},
)
sent_bindings += 1
continue
try:
chunks = _chunk_for_transport(text, limit=3000)
if not chunks:
failed_bindings += 1
continue
ok = True
for chunk in chunks:
ts = await transport.send_message_raw(
binding.service,
binding.channel_identifier,
text=chunk,
attachments=[],
metadata={
"origin_tag": f"bp:{run.id}",
"command_slug": "bp",
},
)
if not ts:
ok = False
break
if ok:
sent_bindings += 1
else:
failed_bindings += 1
except Exception:
failed_bindings += 1
return {"sent_bindings": sent_bindings, "failed_bindings": failed_bindings}
async def execute(self, ctx: CommandContext) -> CommandResult:
trigger = await sync_to_async(
lambda: Message.objects.select_related("user", "session")
.filter(id=ctx.message_id)
.first()
)()
if trigger is None:
return CommandResult(ok=False, status="failed", error="trigger_not_found")
profile = await sync_to_async(
lambda: trigger.user.commandprofile_set.filter(slug=self.slug, enabled=True)
.first()
)()
if profile is None:
return CommandResult(ok=False, status="skipped", error="profile_missing")
actions = await sync_to_async(list)(
CommandAction.objects.filter(
profile=profile,
enabled=True,
).order_by("position", "id")
)
action_types = {row.action_type for row in actions}
if "extract_bp" not in action_types:
return CommandResult(ok=False, status="skipped", error="extract_bp_disabled")
run, created = await sync_to_async(CommandRun.objects.get_or_create)(
profile=profile,
trigger_message=trigger,
defaults={
"user": trigger.user,
"status": "running",
},
)
if not created and run.status in {"ok", "running"}:
return CommandResult(
ok=True,
status="ok",
payload={"document_id": str(run.result_ref_id or "")},
)
run.status = "running"
run.error = ""
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
if trigger.reply_to_id is None:
run.status = "failed"
run.error = "bp_requires_reply_target"
await sync_to_async(run.save)(
update_fields=["status", "error", "updated_at"]
)
return CommandResult(ok=False, status="failed", error=run.error)
anchor = trigger.reply_to
rows = await sync_to_async(list)(
Message.objects.filter(
user=trigger.user,
session=trigger.session,
ts__gte=int(anchor.ts or 0),
ts__lte=int(trigger.ts or 0),
)
.order_by("ts")
.select_related("session", "session__identifier", "session__identifier__person")
)
transcript = messages_to_string(
rows,
author_rewrites={"USER": "Operator", "BOT": "Assistant"},
)
max_transcript_chars = int(
getattr(settings, "BP_MAX_TRANSCRIPT_CHARS", 12000) or 12000
)
transcript = _clamp_transcript(transcript, max_transcript_chars)
default_template = (
"Business Plan:\n"
"- Objective\n"
"- Audience\n"
"- Offer\n"
"- GTM\n"
"- Risks"
)
template_text = profile.template_text or default_template
max_template_chars = int(
getattr(settings, "BP_MAX_TEMPLATE_CHARS", 5000) or 5000
)
template_text = str(template_text or "")[:max_template_chars]
ai_obj = await sync_to_async(
# Match compose draft/engage lookup behavior exactly.
lambda: AI.objects.filter(user=trigger.user).first()
)()
ai_warning = ""
if ai_obj is None:
summary = _bp_fallback_markdown(
template_text,
transcript,
"ai_not_configured",
)
ai_warning = "ai_not_configured"
else:
prompt = [
{"role": "system", "content": _bp_system_prompt()},
{
"role": "user",
"content": (
"Template:\n"
f"{template_text}\n\n"
"Messages:\n"
f"{transcript}"
),
},
]
try:
summary = str(await ai_runner.run_prompt(prompt, ai_obj) or "").strip()
if not summary:
raise RuntimeError("empty_ai_response")
except Exception as exc:
ai_warning = f"bp_ai_failed:{exc}"
summary = _bp_fallback_markdown(
template_text,
transcript,
str(exc),
)
document = await sync_to_async(BusinessPlanDocument.objects.create)(
user=trigger.user,
command_profile=profile,
source_service=trigger.source_service or ctx.service,
source_channel_identifier=trigger.source_chat_id or ctx.channel_identifier,
trigger_message=trigger,
anchor_message=anchor,
title=f"Business Plan {time.strftime('%Y-%m-%d %H:%M:%S')}",
status="draft",
content_markdown=summary,
structured_payload={"source_message_ids": [str(row.id) for row in rows]},
)
await sync_to_async(BusinessPlanRevision.objects.create)(
document=document,
editor_user=trigger.user,
content_markdown=summary,
structured_payload={"source_message_ids": [str(row.id) for row in rows]},
)
fanout_stats = {"sent_bindings": 0, "failed_bindings": 0}
if "post_result" in action_types:
fanout_stats = await self._fanout(run, summary)
if "status_in_source" == profile.visibility_mode:
status_text = f"[bp] Generated business plan: {document.title}"
if ai_warning:
status_text += " (fallback mode)"
sent_count = int(fanout_stats.get("sent_bindings") or 0)
failed_count = int(fanout_stats.get("failed_bindings") or 0)
if sent_count or failed_count:
status_text += f" · fanout sent:{sent_count}"
if failed_count:
status_text += f" failed:{failed_count}"
await self._status_message(trigger, status_text)
run.status = "ok"
run.result_ref = document
run.error = ai_warning
await sync_to_async(run.save)(
update_fields=["status", "result_ref", "error", "updated_at"]
)
return CommandResult(
ok=True,
status="ok",
payload={"document_id": str(document.id)},
)

16
core/commands/registry.py Normal file
View File

@@ -0,0 +1,16 @@
from __future__ import annotations
from core.commands.base import CommandHandler
_HANDLERS: dict[str, CommandHandler] = {}
def register(handler: CommandHandler):
slug = str(getattr(handler, "slug", "") or "").strip().lower()
if not slug:
raise ValueError("handler slug is required")
_HANDLERS[slug] = handler
def get(slug: str) -> CommandHandler | None:
return _HANDLERS.get(str(slug or "").strip().lower())

View File

@@ -144,7 +144,20 @@ async def get_chat_session(user, identifier):
return chat_session return chat_session
async def store_message(session, sender, text, ts, outgoing=False): async def store_message(
session,
sender,
text,
ts,
outgoing=False,
source_service="",
source_message_id="",
source_chat_id="",
reply_to=None,
reply_source_service="",
reply_source_message_id="",
message_meta=None,
):
log.debug("Storing message for session=%s outgoing=%s", session.id, outgoing) log.debug("Storing message for session=%s outgoing=%s", session.id, outgoing)
msg = await sync_to_async(Message.objects.create)( msg = await sync_to_async(Message.objects.create)(
user=session.user, user=session.user,
@@ -154,12 +167,32 @@ async def store_message(session, sender, text, ts, outgoing=False):
ts=ts, ts=ts,
delivered_ts=ts, delivered_ts=ts,
custom_author="USER" if outgoing else None, custom_author="USER" if outgoing else None,
source_service=(source_service or None),
source_message_id=str(source_message_id or "").strip() or None,
source_chat_id=str(source_chat_id or "").strip() or None,
reply_to=reply_to,
reply_source_service=str(reply_source_service or "").strip() or None,
reply_source_message_id=str(reply_source_message_id or "").strip() or None,
message_meta=dict(message_meta or {}),
) )
return msg return msg
async def store_own_message(session, text, ts, manip=None, queue=False): async def store_own_message(
session,
text,
ts,
manip=None,
queue=False,
source_service="",
source_message_id="",
source_chat_id="",
reply_to=None,
reply_source_service="",
reply_source_message_id="",
message_meta=None,
):
log.debug("Storing own message for session=%s queue=%s", session.id, queue) log.debug("Storing own message for session=%s queue=%s", session.id, queue)
cast = { cast = {
"user": session.user, "user": session.user,
@@ -168,6 +201,13 @@ async def store_own_message(session, text, ts, manip=None, queue=False):
"text": text, "text": text,
"ts": ts, "ts": ts,
"delivered_ts": ts, "delivered_ts": ts,
"source_service": (source_service or None),
"source_message_id": str(source_message_id or "").strip() or None,
"source_chat_id": str(source_chat_id or "").strip() or None,
"reply_to": reply_to,
"reply_source_service": str(reply_source_service or "").strip() or None,
"reply_source_message_id": str(reply_source_message_id or "").strip() or None,
"message_meta": dict(message_meta or {}),
} }
if queue: if queue:
msg_object = QueuedMessage msg_object = QueuedMessage

View File

@@ -0,0 +1,391 @@
from __future__ import annotations
import re
from typing import Any
from asgiref.sync import sync_to_async
from core.messaging import history
from core.models import Message
def _as_dict(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, dict) else {}
def _pluck(data: Any, *path: str):
cur = data
for key in path:
if isinstance(cur, dict):
cur = cur.get(key)
continue
if hasattr(cur, key):
cur = getattr(cur, key)
continue
return None
return cur
def _clean(value: Any) -> str:
return str(value or "").strip()
def _find_origin_tag(value: Any, depth: int = 0) -> str:
if depth > 4:
return ""
if isinstance(value, dict):
direct = _clean(value.get("origin_tag"))
if direct:
return direct
for key in ("metadata", "meta", "message_meta", "contextInfo", "context_info"):
nested = _find_origin_tag(value.get(key), depth + 1)
if nested:
return nested
for nested_value in value.values():
nested = _find_origin_tag(nested_value, depth + 1)
if nested:
return nested
return ""
if isinstance(value, list):
for item in value:
nested = _find_origin_tag(item, depth + 1)
if nested:
return nested
return ""
def _extract_signal_reply(raw_payload: dict[str, Any]) -> dict[str, str]:
envelope = _as_dict((raw_payload or {}).get("envelope"))
data_message = _as_dict(
envelope.get("dataMessage")
or envelope.get("syncMessage", {}).get("sentMessage", {}).get("message")
)
quote = _as_dict(data_message.get("quote"))
quote_id = _clean(quote.get("id"))
if quote_id:
return {
"reply_source_message_id": quote_id,
"reply_source_service": "signal",
"reply_source_chat_id": "",
}
return {}
def _extract_whatsapp_reply(raw_payload: dict[str, Any]) -> dict[str, str]:
# Handles common and nested contextInfo/messageContextInfo shapes for
# WhatsApp payloads (extended text, media, ephemeral, view-once wrappers).
candidate_paths = (
("contextInfo",),
("ContextInfo",),
("messageContextInfo",),
("MessageContextInfo",),
("extendedTextMessage", "contextInfo"),
("ExtendedTextMessage", "ContextInfo"),
("imageMessage", "contextInfo"),
("ImageMessage", "ContextInfo"),
("videoMessage", "contextInfo"),
("VideoMessage", "ContextInfo"),
("documentMessage", "contextInfo"),
("DocumentMessage", "ContextInfo"),
("ephemeralMessage", "message", "contextInfo"),
("ephemeralMessage", "message", "extendedTextMessage", "contextInfo"),
("viewOnceMessage", "message", "contextInfo"),
("viewOnceMessage", "message", "extendedTextMessage", "contextInfo"),
("viewOnceMessageV2", "message", "contextInfo"),
("viewOnceMessageV2", "message", "extendedTextMessage", "contextInfo"),
("viewOnceMessageV2Extension", "message", "contextInfo"),
("viewOnceMessageV2Extension", "message", "extendedTextMessage", "contextInfo"),
# snake_case protobuf dict variants
("context_info",),
("message_context_info",),
("extended_text_message", "context_info"),
("image_message", "context_info"),
("video_message", "context_info"),
("document_message", "context_info"),
("ephemeral_message", "message", "context_info"),
("ephemeral_message", "message", "extended_text_message", "context_info"),
("view_once_message", "message", "context_info"),
("view_once_message", "message", "extended_text_message", "context_info"),
("view_once_message_v2", "message", "context_info"),
("view_once_message_v2", "message", "extended_text_message", "context_info"),
("view_once_message_v2_extension", "message", "context_info"),
(
"view_once_message_v2_extension",
"message",
"extended_text_message",
"context_info",
),
)
contexts = []
for path in candidate_paths:
row = _as_dict(_pluck(raw_payload, *path))
if row:
contexts.append(row)
# Recursive fallback for unknown wrapper shapes.
stack = [_as_dict(raw_payload)]
while stack:
current = stack.pop()
if not isinstance(current, dict):
continue
if isinstance(current.get("contextInfo"), dict):
contexts.append(_as_dict(current.get("contextInfo")))
if isinstance(current.get("ContextInfo"), dict):
contexts.append(_as_dict(current.get("ContextInfo")))
if isinstance(current.get("messageContextInfo"), dict):
contexts.append(_as_dict(current.get("messageContextInfo")))
if isinstance(current.get("MessageContextInfo"), dict):
contexts.append(_as_dict(current.get("MessageContextInfo")))
if isinstance(current.get("context_info"), dict):
contexts.append(_as_dict(current.get("context_info")))
if isinstance(current.get("message_context_info"), dict):
contexts.append(_as_dict(current.get("message_context_info")))
for value in current.values():
if isinstance(value, dict):
stack.append(value)
elif isinstance(value, list):
for item in value:
if isinstance(item, dict):
stack.append(item)
for context in contexts:
stanza_id = _clean(
context.get("stanzaId")
or context.get("stanzaID")
or context.get("stanza_id")
or context.get("StanzaId")
or context.get("StanzaID")
or context.get("quotedMessageID")
or context.get("quotedMessageId")
or context.get("QuotedMessageID")
or context.get("QuotedMessageId")
or _pluck(context, "quotedMessageKey", "id")
or _pluck(context, "quoted_message_key", "id")
or _pluck(context, "quotedMessage", "key", "id")
or _pluck(context, "quoted_message", "key", "id")
)
if not stanza_id:
continue
participant = _clean(
context.get("participant")
or context.get("remoteJid")
or context.get("chat")
or context.get("Participant")
or context.get("RemoteJid")
or context.get("RemoteJID")
or context.get("Chat")
)
return {
"reply_source_message_id": stanza_id,
"reply_source_service": "whatsapp",
"reply_source_chat_id": participant,
}
return {}
def extract_whatsapp_reply_debug(raw_payload: dict[str, Any]) -> dict[str, Any]:
payload = _as_dict(raw_payload)
candidate_paths = (
("contextInfo",),
("ContextInfo",),
("messageContextInfo",),
("MessageContextInfo",),
("extendedTextMessage", "contextInfo"),
("ExtendedTextMessage", "ContextInfo"),
("imageMessage", "contextInfo"),
("ImageMessage", "ContextInfo"),
("videoMessage", "contextInfo"),
("VideoMessage", "ContextInfo"),
("documentMessage", "contextInfo"),
("DocumentMessage", "ContextInfo"),
("ephemeralMessage", "message", "contextInfo"),
("ephemeralMessage", "message", "extendedTextMessage", "contextInfo"),
("viewOnceMessage", "message", "contextInfo"),
("viewOnceMessage", "message", "extendedTextMessage", "contextInfo"),
("viewOnceMessageV2", "message", "contextInfo"),
("viewOnceMessageV2", "message", "extendedTextMessage", "contextInfo"),
("viewOnceMessageV2Extension", "message", "contextInfo"),
("viewOnceMessageV2Extension", "message", "extendedTextMessage", "contextInfo"),
("context_info",),
("message_context_info",),
("extended_text_message", "context_info"),
("image_message", "context_info"),
("video_message", "context_info"),
("document_message", "context_info"),
("ephemeral_message", "message", "context_info"),
("ephemeral_message", "message", "extended_text_message", "context_info"),
("view_once_message", "message", "context_info"),
("view_once_message", "message", "extended_text_message", "context_info"),
("view_once_message_v2", "message", "context_info"),
("view_once_message_v2", "message", "extended_text_message", "context_info"),
("view_once_message_v2_extension", "message", "context_info"),
(
"view_once_message_v2_extension",
"message",
"extended_text_message",
"context_info",
),
)
rows = []
for path in candidate_paths:
context = _as_dict(_pluck(payload, *path))
if not context:
continue
rows.append(
{
"path": ".".join(path),
"keys": sorted([str(key) for key in context.keys()])[:40],
"stanzaId": _clean(
context.get("stanzaId")
or context.get("stanzaID")
or context.get("stanza_id")
or context.get("StanzaId")
or context.get("StanzaID")
or context.get("quotedMessageID")
or context.get("quotedMessageId")
or context.get("QuotedMessageID")
or context.get("QuotedMessageId")
or _pluck(context, "quotedMessageKey", "id")
or _pluck(context, "quoted_message_key", "id")
or _pluck(context, "quotedMessage", "key", "id")
or _pluck(context, "quoted_message", "key", "id")
),
"participant": _clean(
context.get("participant")
or context.get("remoteJid")
or context.get("chat")
or context.get("Participant")
or context.get("RemoteJid")
or context.get("RemoteJID")
or context.get("Chat")
),
}
)
return {
"candidate_count": len(rows),
"candidates": rows[:20],
}
def extract_reply_ref(service: str, raw_payload: dict[str, Any]) -> dict[str, str]:
svc = _clean(service).lower()
payload = _as_dict(raw_payload)
if svc == "xmpp":
reply_id = _clean(payload.get("reply_source_message_id") or payload.get("reply_id"))
reply_chat = _clean(payload.get("reply_source_chat_id") or payload.get("reply_chat_id"))
if reply_id:
return {
"reply_source_message_id": reply_id,
"reply_source_service": "xmpp",
"reply_source_chat_id": reply_chat,
}
return {}
if svc == "signal":
return _extract_signal_reply(payload)
if svc == "whatsapp":
return _extract_whatsapp_reply(payload)
if svc == "web":
reply_id = _clean(payload.get("reply_to_message_id"))
if reply_id:
return {
"reply_source_message_id": reply_id,
"reply_source_service": "web",
"reply_source_chat_id": _clean(payload.get("reply_source_chat_id")),
}
return {}
def extract_origin_tag(raw_payload: dict[str, Any] | None) -> str:
return _find_origin_tag(_as_dict(raw_payload))
async def resolve_reply_target(user, session, reply_ref: dict[str, str]) -> Message | None:
if not reply_ref or session is None:
return None
reply_source_message_id = _clean(reply_ref.get("reply_source_message_id"))
if not reply_source_message_id:
return None
# Direct local UUID fallback (web compose references local Message IDs).
if re.fullmatch(
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
reply_source_message_id,
):
direct = await sync_to_async(
lambda: Message.objects.filter(
user=user,
session=session,
id=reply_source_message_id,
).first()
)()
if direct is not None:
return direct
source_service = _clean(reply_ref.get("reply_source_service"))
by_source = await sync_to_async(
lambda: Message.objects.filter(
user=user,
session=session,
source_service=source_service or None,
source_message_id=reply_source_message_id,
)
.order_by("-ts")
.first()
)()
if by_source is not None:
return by_source
# Bridge ref fallback: resolve replies against bridge mappings persisted in
# message receipt payloads.
identifier = getattr(session, "identifier", None)
if identifier is not None:
service_candidates = []
if source_service:
service_candidates.append(source_service)
# XMPP replies can target bridged messages from any external service.
if source_service == "xmpp":
service_candidates.extend(["signal", "whatsapp", "instagram"])
for candidate in service_candidates:
bridge = await history.resolve_bridge_ref(
user=user,
identifier=identifier,
source_service=candidate,
xmpp_message_id=reply_source_message_id,
upstream_message_id=reply_source_message_id,
)
local_message_id = _clean((bridge or {}).get("local_message_id"))
if not local_message_id:
continue
bridged = await sync_to_async(
lambda: Message.objects.filter(
user=user,
session=session,
id=local_message_id,
).first()
)()
if bridged is not None:
return bridged
fallback = await sync_to_async(
lambda: Message.objects.filter(
user=user,
session=session,
reply_source_message_id=reply_source_message_id,
)
.order_by("-ts")
.first()
)()
return fallback
def apply_sync_origin(message_meta: dict | None, origin_tag: str) -> dict:
payload = dict(message_meta or {})
tag = _clean(origin_tag)
if not tag:
return payload
payload["origin_tag"] = tag
return payload
def is_mirrored_origin(message_meta: dict | None) -> bool:
payload = dict(message_meta or {})
return bool(_clean(payload.get("origin_tag")))

View File

@@ -0,0 +1,311 @@
# Generated by Django 5.2.7 on 2026-03-01 20:03
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0026_platformchatlink_is_group'),
]
operations = [
migrations.CreateModel(
name='BusinessPlanDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('source_service', models.CharField(choices=[('signal', 'Signal'), ('whatsapp', 'WhatsApp'), ('xmpp', 'XMPP'), ('instagram', 'Instagram'), ('web', 'Web')], max_length=255)),
('source_channel_identifier', models.CharField(blank=True, default='', max_length=255)),
('title', models.CharField(default='Business Plan', max_length=255)),
('status', models.CharField(choices=[('draft', 'Draft'), ('final', 'Final')], default='draft', max_length=32)),
('content_markdown', models.TextField(blank=True, default='')),
('structured_payload', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='BusinessPlanRevision',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content_markdown', models.TextField(blank=True, default='')),
('structured_payload', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['created_at'],
},
),
migrations.CreateModel(
name='CommandAction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action_type', models.CharField(choices=[('extract_bp', 'Extract Business Plan'), ('post_result', 'Post Result'), ('save_document', 'Save Document')], max_length=64)),
('enabled', models.BooleanField(default=True)),
('config', models.JSONField(blank=True, default=dict)),
('position', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['position', 'id'],
},
),
migrations.CreateModel(
name='CommandChannelBinding',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('direction', models.CharField(choices=[('ingress', 'Ingress'), ('egress', 'Egress'), ('scratchpad_mirror', 'Scratchpad Mirror')], max_length=64)),
('service', models.CharField(choices=[('signal', 'Signal'), ('whatsapp', 'WhatsApp'), ('xmpp', 'XMPP'), ('instagram', 'Instagram'), ('web', 'Web')], max_length=255)),
('channel_identifier', models.CharField(max_length=255)),
('enabled', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='CommandProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.CharField(default='bp', max_length=64)),
('name', models.CharField(default='Business Plan', max_length=255)),
('enabled', models.BooleanField(default=True)),
('trigger_token', models.CharField(default='#bp#', max_length=64)),
('reply_required', models.BooleanField(default=True)),
('exact_match_only', models.BooleanField(default=True)),
('window_scope', models.CharField(choices=[('conversation', 'Conversation')], default='conversation', max_length=64)),
('template_text', models.TextField(blank=True, default='')),
('visibility_mode', models.CharField(choices=[('status_in_source', 'Status In Source'), ('silent', 'Silent')], default='status_in_source', max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='CommandRun',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('ok', 'OK'), ('failed', 'Failed'), ('skipped', 'Skipped')], default='pending', max_length=32)),
('error', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='TranslationBridge',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='Translation Bridge', max_length=255)),
('enabled', models.BooleanField(default=True)),
('a_service', models.CharField(choices=[('signal', 'Signal'), ('whatsapp', 'WhatsApp'), ('xmpp', 'XMPP'), ('instagram', 'Instagram'), ('web', 'Web')], max_length=255)),
('a_channel_identifier', models.CharField(max_length=255)),
('a_language', models.CharField(default='en', max_length=64)),
('b_service', models.CharField(choices=[('signal', 'Signal'), ('whatsapp', 'WhatsApp'), ('xmpp', 'XMPP'), ('instagram', 'Instagram'), ('web', 'Web')], max_length=255)),
('b_channel_identifier', models.CharField(max_length=255)),
('b_language', models.CharField(default='en', max_length=64)),
('direction', models.CharField(choices=[('a_to_b', 'A To B'), ('b_to_a', 'B To A'), ('bidirectional', 'Bidirectional')], default='bidirectional', max_length=32)),
('quick_mode_title', models.CharField(blank=True, default='', max_length=255)),
('settings', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='TranslationEventLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('target_service', models.CharField(choices=[('signal', 'Signal'), ('whatsapp', 'WhatsApp'), ('xmpp', 'XMPP'), ('instagram', 'Instagram'), ('web', 'Web')], max_length=255)),
('target_channel', models.CharField(max_length=255)),
('status', models.CharField(choices=[('pending', 'Pending'), ('ok', 'OK'), ('failed', 'Failed'), ('skipped', 'Skipped')], default='pending', max_length=32)),
('error', models.TextField(blank=True, default='')),
('origin_tag', models.CharField(blank=True, default='', max_length=255)),
('content_hash', models.CharField(blank=True, default='', max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.AddField(
model_name='message',
name='message_meta',
field=models.JSONField(blank=True, default=dict, help_text='Normalized message metadata such as origin tags.'),
),
migrations.AddField(
model_name='message',
name='reply_source_message_id',
field=models.CharField(blank=True, help_text='Source message id for the replied target.', max_length=255, null=True),
),
migrations.AddField(
model_name='message',
name='reply_source_service',
field=models.CharField(blank=True, choices=[('signal', 'Signal'), ('whatsapp', 'WhatsApp'), ('xmpp', 'XMPP'), ('instagram', 'Instagram'), ('web', 'Web')], help_text='Source service for the replied target.', max_length=255, null=True),
),
migrations.AddField(
model_name='message',
name='reply_to',
field=models.ForeignKey(blank=True, help_text='Resolved local message this message replies to.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reply_children', to='core.message'),
),
migrations.AddField(
model_name='message',
name='source_chat_id',
field=models.CharField(blank=True, help_text='Source service chat or thread identifier when available.', max_length=255, null=True),
),
migrations.AddField(
model_name='message',
name='source_message_id',
field=models.CharField(blank=True, help_text='Source service message id when available.', max_length=255, null=True),
),
migrations.AddField(
model_name='message',
name='source_service',
field=models.CharField(blank=True, choices=[('signal', 'Signal'), ('whatsapp', 'WhatsApp'), ('xmpp', 'XMPP'), ('instagram', 'Instagram'), ('web', 'Web')], help_text='Source service where this message originally appeared.', max_length=255, null=True),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['user', 'source_service', 'source_message_id'], name='core_messag_user_id_252699_idx'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['user', 'session', 'ts'], name='core_messag_user_id_ba0e73_idx'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['user', 'reply_source_service', 'reply_source_message_id'], name='core_messag_user_id_70ca93_idx'),
),
migrations.AddField(
model_name='businessplandocument',
name='anchor_message',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='business_plan_anchor_docs', to='core.message'),
),
migrations.AddField(
model_name='businessplandocument',
name='trigger_message',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='business_plan_trigger_docs', to='core.message'),
),
migrations.AddField(
model_name='businessplandocument',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='businessplanrevision',
name='document',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='core.businessplandocument'),
),
migrations.AddField(
model_name='businessplanrevision',
name='editor_user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='commandprofile',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='commandchannelbinding',
name='profile',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='channel_bindings', to='core.commandprofile'),
),
migrations.AddField(
model_name='commandaction',
name='profile',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to='core.commandprofile'),
),
migrations.AddField(
model_name='businessplandocument',
name='command_profile',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='business_plan_documents', to='core.commandprofile'),
),
migrations.AddField(
model_name='commandrun',
name='profile',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='runs', to='core.commandprofile'),
),
migrations.AddField(
model_name='commandrun',
name='result_ref',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='command_runs', to='core.businessplandocument'),
),
migrations.AddField(
model_name='commandrun',
name='trigger_message',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='command_runs', to='core.message'),
),
migrations.AddField(
model_name='commandrun',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='translationbridge',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='translationeventlog',
name='bridge',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='core.translationbridge'),
),
migrations.AddField(
model_name='translationeventlog',
name='source_message',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='translation_events', to='core.message'),
),
migrations.AddConstraint(
model_name='commandprofile',
constraint=models.UniqueConstraint(fields=('user', 'slug'), name='unique_command_profile_per_user'),
),
migrations.AddIndex(
model_name='commandchannelbinding',
index=models.Index(fields=['profile', 'direction', 'service'], name='core_comman_profile_6c16d5_idx'),
),
migrations.AddIndex(
model_name='commandchannelbinding',
index=models.Index(fields=['profile', 'service', 'channel_identifier'], name='core_comman_profile_2c801d_idx'),
),
migrations.AddIndex(
model_name='commandaction',
index=models.Index(fields=['profile', 'action_type', 'enabled'], name='core_comman_profile_f8e752_idx'),
),
migrations.AddIndex(
model_name='businessplandocument',
index=models.Index(fields=['user', 'status', 'updated_at'], name='core_busine_user_id_028f36_idx'),
),
migrations.AddIndex(
model_name='businessplandocument',
index=models.Index(fields=['user', 'source_service', 'source_channel_identifier'], name='core_busine_user_id_54ef14_idx'),
),
migrations.AddIndex(
model_name='commandrun',
index=models.Index(fields=['user', 'status', 'updated_at'], name='core_comman_user_id_aa2881_idx'),
),
migrations.AddConstraint(
model_name='commandrun',
constraint=models.UniqueConstraint(fields=('profile', 'trigger_message'), name='unique_command_run_profile_trigger_message'),
),
migrations.AddIndex(
model_name='translationbridge',
index=models.Index(fields=['user', 'enabled'], name='core_transl_user_id_ce99cd_idx'),
),
migrations.AddIndex(
model_name='translationbridge',
index=models.Index(fields=['user', 'a_service', 'a_channel_identifier'], name='core_transl_user_id_2f26ee_idx'),
),
migrations.AddIndex(
model_name='translationbridge',
index=models.Index(fields=['user', 'b_service', 'b_channel_identifier'], name='core_transl_user_id_1f910a_idx'),
),
migrations.AddIndex(
model_name='translationeventlog',
index=models.Index(fields=['bridge', 'created_at'], name='core_transl_bridge__509ffc_idx'),
),
migrations.AddIndex(
model_name='translationeventlog',
index=models.Index(fields=['bridge', 'status', 'updated_at'], name='core_transl_bridge__0a7676_idx'),
),
migrations.AddIndex(
model_name='translationeventlog',
index=models.Index(fields=['origin_tag'], name='core_transl_origin__a5c2f3_idx'),
),
]

View File

@@ -18,6 +18,7 @@ SERVICE_CHOICES = (
("xmpp", "XMPP"), ("xmpp", "XMPP"),
("instagram", "Instagram"), ("instagram", "Instagram"),
) )
CHANNEL_SERVICE_CHOICES = SERVICE_CHOICES + (("web", "Web"),)
MBTI_CHOICES = ( MBTI_CHOICES = (
("INTJ", "INTJ - Architect"), ("INTJ", "INTJ - Architect"),
("INTP", "INTP - Logician"), ("INTP", "INTP - Logician"),
@@ -297,9 +298,61 @@ class Message(models.Model):
blank=True, blank=True,
help_text="Raw normalized delivery/read receipt metadata.", help_text="Raw normalized delivery/read receipt metadata.",
) )
source_service = models.CharField(
max_length=255,
choices=CHANNEL_SERVICE_CHOICES,
null=True,
blank=True,
help_text="Source service where this message originally appeared.",
)
source_message_id = models.CharField(
max_length=255,
null=True,
blank=True,
help_text="Source service message id when available.",
)
source_chat_id = models.CharField(
max_length=255,
null=True,
blank=True,
help_text="Source service chat or thread identifier when available.",
)
reply_to = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="reply_children",
help_text="Resolved local message this message replies to.",
)
reply_source_service = models.CharField(
max_length=255,
choices=CHANNEL_SERVICE_CHOICES,
null=True,
blank=True,
help_text="Source service for the replied target.",
)
reply_source_message_id = models.CharField(
max_length=255,
null=True,
blank=True,
help_text="Source message id for the replied target.",
)
message_meta = models.JSONField(
default=dict,
blank=True,
help_text="Normalized message metadata such as origin tags.",
)
class Meta: class Meta:
ordering = ["ts"] ordering = ["ts"]
indexes = [
models.Index(fields=["user", "source_service", "source_message_id"]),
models.Index(fields=["user", "session", "ts"]),
models.Index(
fields=["user", "reply_source_service", "reply_source_message_id"]
),
]
class Group(models.Model): class Group(models.Model):
@@ -1568,6 +1621,270 @@ class PatternArtifactExport(models.Model):
) )
class CommandProfile(models.Model):
WINDOW_SCOPE_CHOICES = (
("conversation", "Conversation"),
)
VISIBILITY_CHOICES = (
("status_in_source", "Status In Source"),
("silent", "Silent"),
)
user = models.ForeignKey(User, on_delete=models.CASCADE)
slug = models.CharField(max_length=64, default="bp")
name = models.CharField(max_length=255, default="Business Plan")
enabled = models.BooleanField(default=True)
trigger_token = models.CharField(max_length=64, default="#bp#")
reply_required = models.BooleanField(default=True)
exact_match_only = models.BooleanField(default=True)
window_scope = models.CharField(
max_length=64,
choices=WINDOW_SCOPE_CHOICES,
default="conversation",
)
template_text = models.TextField(blank=True, default="")
visibility_mode = models.CharField(
max_length=64,
choices=VISIBILITY_CHOICES,
default="status_in_source",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["user", "slug"],
name="unique_command_profile_per_user",
)
]
def __str__(self):
return f"{self.user_id}:{self.slug}"
class CommandChannelBinding(models.Model):
DIRECTION_CHOICES = (
("ingress", "Ingress"),
("egress", "Egress"),
("scratchpad_mirror", "Scratchpad Mirror"),
)
profile = models.ForeignKey(
CommandProfile,
on_delete=models.CASCADE,
related_name="channel_bindings",
)
direction = models.CharField(max_length=64, choices=DIRECTION_CHOICES)
service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
channel_identifier = models.CharField(max_length=255)
enabled = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
indexes = [
models.Index(fields=["profile", "direction", "service"]),
models.Index(fields=["profile", "service", "channel_identifier"]),
]
class CommandAction(models.Model):
ACTION_CHOICES = (
("extract_bp", "Extract Business Plan"),
("post_result", "Post Result"),
("save_document", "Save Document"),
)
profile = models.ForeignKey(
CommandProfile,
on_delete=models.CASCADE,
related_name="actions",
)
action_type = models.CharField(max_length=64, choices=ACTION_CHOICES)
enabled = models.BooleanField(default=True)
config = models.JSONField(default=dict, blank=True)
position = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["position", "id"]
indexes = [models.Index(fields=["profile", "action_type", "enabled"])]
class BusinessPlanDocument(models.Model):
STATUS_CHOICES = (
("draft", "Draft"),
("final", "Final"),
)
user = models.ForeignKey(User, on_delete=models.CASCADE)
command_profile = models.ForeignKey(
CommandProfile,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="business_plan_documents",
)
source_service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
source_channel_identifier = models.CharField(max_length=255, blank=True, default="")
trigger_message = models.ForeignKey(
Message,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="business_plan_trigger_docs",
)
anchor_message = models.ForeignKey(
Message,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="business_plan_anchor_docs",
)
title = models.CharField(max_length=255, default="Business Plan")
status = models.CharField(max_length=32, choices=STATUS_CHOICES, default="draft")
content_markdown = models.TextField(blank=True, default="")
structured_payload = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
indexes = [
models.Index(fields=["user", "status", "updated_at"]),
models.Index(fields=["user", "source_service", "source_channel_identifier"]),
]
class BusinessPlanRevision(models.Model):
document = models.ForeignKey(
BusinessPlanDocument,
on_delete=models.CASCADE,
related_name="revisions",
)
editor_user = models.ForeignKey(User, on_delete=models.CASCADE)
content_markdown = models.TextField(blank=True, default="")
structured_payload = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["created_at"]
class CommandRun(models.Model):
STATUS_CHOICES = (
("pending", "Pending"),
("running", "Running"),
("ok", "OK"),
("failed", "Failed"),
("skipped", "Skipped"),
)
user = models.ForeignKey(User, on_delete=models.CASCADE)
profile = models.ForeignKey(
CommandProfile,
on_delete=models.CASCADE,
related_name="runs",
)
trigger_message = models.ForeignKey(
Message,
on_delete=models.CASCADE,
related_name="command_runs",
)
status = models.CharField(max_length=32, choices=STATUS_CHOICES, default="pending")
error = models.TextField(blank=True, default="")
result_ref = models.ForeignKey(
BusinessPlanDocument,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="command_runs",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["profile", "trigger_message"],
name="unique_command_run_profile_trigger_message",
)
]
indexes = [models.Index(fields=["user", "status", "updated_at"])]
class TranslationBridge(models.Model):
DIRECTION_CHOICES = (
("a_to_b", "A To B"),
("b_to_a", "B To A"),
("bidirectional", "Bidirectional"),
)
user = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=255, default="Translation Bridge")
enabled = models.BooleanField(default=True)
a_service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
a_channel_identifier = models.CharField(max_length=255)
a_language = models.CharField(max_length=64, default="en")
b_service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
b_channel_identifier = models.CharField(max_length=255)
b_language = models.CharField(max_length=64, default="en")
direction = models.CharField(
max_length=32,
choices=DIRECTION_CHOICES,
default="bidirectional",
)
quick_mode_title = models.CharField(max_length=255, blank=True, default="")
settings = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
indexes = [
models.Index(fields=["user", "enabled"]),
models.Index(fields=["user", "a_service", "a_channel_identifier"]),
models.Index(fields=["user", "b_service", "b_channel_identifier"]),
]
class TranslationEventLog(models.Model):
STATUS_CHOICES = (
("pending", "Pending"),
("ok", "OK"),
("failed", "Failed"),
("skipped", "Skipped"),
)
bridge = models.ForeignKey(
TranslationBridge,
on_delete=models.CASCADE,
related_name="events",
)
source_message = models.ForeignKey(
Message,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="translation_events",
)
target_service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
target_channel = models.CharField(max_length=255)
status = models.CharField(max_length=32, choices=STATUS_CHOICES, default="pending")
error = models.TextField(blank=True, default="")
origin_tag = models.CharField(max_length=255, blank=True, default="")
content_hash = models.CharField(max_length=255, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
indexes = [
models.Index(fields=["bridge", "created_at"]),
models.Index(fields=["bridge", "status", "updated_at"]),
models.Index(fields=["origin_tag"]),
]
# class Perms(models.Model): # class Perms(models.Model):
# class Meta: # class Meta:
# permissions = ( # permissions = (

View File

@@ -8,9 +8,12 @@ from core.clients.instagram import InstagramClient
from core.clients.signal import SignalClient from core.clients.signal import SignalClient
from core.clients.whatsapp import WhatsAppClient from core.clients.whatsapp import WhatsAppClient
from core.clients.xmpp import XMPPClient from core.clients.xmpp import XMPPClient
from core.commands.base import CommandContext
from core.commands.engine import process_inbound_message
from core.messaging import history from core.messaging import history
from core.models import PersonIdentifier from core.models import PersonIdentifier
from core.realtime.typing_state import set_person_typing_state from core.realtime.typing_state import set_person_typing_state
from core.translation.engine import process_inbound_translation
from core.util import logs from core.util import logs
@@ -91,6 +94,34 @@ class UnifiedRouter(object):
async def message_received(self, protocol, *args, **kwargs): async def message_received(self, protocol, *args, **kwargs):
self.log.info(f"Message received ({protocol}) {args} {kwargs}") self.log.info(f"Message received ({protocol}) {args} {kwargs}")
identifier = kwargs.get("identifier")
local_message = kwargs.get("local_message")
message_text = str(kwargs.get("text") or "").strip()
if local_message is None:
return
channel_identifier = ""
if isinstance(identifier, PersonIdentifier):
channel_identifier = str(identifier.identifier or "").strip()
elif identifier is not None:
channel_identifier = str(identifier or "").strip()
if channel_identifier:
try:
await process_inbound_message(
CommandContext(
service=str(protocol or "").strip().lower(),
channel_identifier=channel_identifier,
message_id=str(local_message.id),
user_id=int(local_message.user_id),
message_text=message_text,
payload=dict(kwargs.get("payload") or {}),
)
)
except Exception as exc:
self.log.warning("Command engine processing failed: %s", exc)
try:
await process_inbound_translation(local_message)
except Exception as exc:
self.log.warning("Translation engine processing failed: %s", exc)
async def _resolve_identifier_objects(self, protocol, identifier): async def _resolve_identifier_objects(self, protocol, identifier):
if isinstance(identifier, PersonIdentifier): if isinstance(identifier, PersonIdentifier):

View File

@@ -392,6 +392,9 @@
<a class="navbar-item" href="{% url 'ais' type='page' %}"> <a class="navbar-item" href="{% url 'ais' type='page' %}">
AI AI
</a> </a>
<a class="navbar-item" href="{% url 'command_routing' %}">
Command Routing
</a>
{% if user.is_superuser %} {% if user.is_superuser %}
<a class="navbar-item" href="{% url 'system_settings' %}"> <a class="navbar-item" href="{% url 'system_settings' %}">
System System

View File

@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title is-4">Business Plan Editor</h1>
<p class="subtitle is-6">{{ document.source_service }} · {{ document.source_channel_identifier }}</p>
<article class="box">
<form method="post">
{% csrf_token %}
<div class="columns">
<div class="column is-8">
<label class="label is-size-7">Title</label>
<input class="input" name="title" value="{{ document.title }}">
</div>
<div class="column is-4">
<label class="label is-size-7">Status</label>
<div class="select is-fullwidth">
<select name="status">
<option value="draft" {% if document.status == 'draft' %}selected{% endif %}>draft</option>
<option value="final" {% if document.status == 'final' %}selected{% endif %}>final</option>
</select>
</div>
</div>
</div>
<label class="label is-size-7">Content (Markdown)</label>
<textarea class="textarea" name="content_markdown" rows="18">{{ document.content_markdown }}</textarea>
<div class="buttons" style="margin-top: 0.75rem;">
<button class="button is-link" type="submit">Save Revision</button>
<a class="button is-light" href="{% url 'command_routing' %}">Back</a>
</div>
</form>
</article>
<article class="box">
<h2 class="title is-6">Revisions</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead>
<tr><th>Created</th><th>Editor</th><th>Excerpt</th></tr>
</thead>
<tbody>
{% for row in revisions %}
<tr>
<td>{{ row.created_at }}</td>
<td>{{ row.editor_user.username }}</td>
<td>{{ row.content_markdown|truncatechars:180 }}</td>
</tr>
{% empty %}
<tr><td colspan="3">No revisions yet.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,267 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title is-4">Command Routing</h1>
<p class="subtitle is-6">Manage command profiles, channel bindings, business-plan outputs, and translation bridges.</p>
<article class="box">
<h2 class="title is-6">Create Command Profile</h2>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="profile_create">
<div class="columns">
<div class="column">
<input class="input is-small" name="slug" placeholder="slug (bp)" value="bp">
</div>
<div class="column">
<input class="input is-small" name="name" placeholder="name" value="Business Plan">
</div>
<div class="column">
<input class="input is-small" name="trigger_token" placeholder="trigger token" value="#bp#">
</div>
</div>
<textarea class="textarea is-small" name="template_text" rows="4" placeholder="Business plan template"></textarea>
<button class="button is-link is-small" type="submit">Create Profile</button>
</form>
</article>
{% for profile in profiles %}
<article class="box">
<h2 class="title is-6">{{ profile.name }} ({{ profile.slug }})</h2>
<form method="post" style="margin-bottom: 0.75rem;">
{% csrf_token %}
<input type="hidden" name="action" value="profile_update">
<input type="hidden" name="profile_id" value="{{ profile.id }}">
<div class="columns is-multiline">
<div class="column is-3">
<label class="label is-size-7">Name</label>
<input class="input is-small" name="name" value="{{ profile.name }}">
</div>
<div class="column is-2">
<label class="label is-size-7">Trigger</label>
<input class="input is-small" name="trigger_token" value="{{ profile.trigger_token }}">
</div>
<div class="column is-2">
<label class="label is-size-7">Visibility</label>
<div class="select is-small is-fullwidth">
<select name="visibility_mode">
<option value="status_in_source" {% if profile.visibility_mode == 'status_in_source' %}selected{% endif %}>status_in_source</option>
<option value="silent" {% if profile.visibility_mode == 'silent' %}selected{% endif %}>silent</option>
</select>
</div>
</div>
<div class="column is-5">
<label class="label is-size-7">Flags</label>
<label class="checkbox is-size-7"><input type="checkbox" name="enabled" value="1" {% if profile.enabled %}checked{% endif %}> enabled</label>
<label class="checkbox is-size-7" style="margin-left: 0.6rem;"><input type="checkbox" name="reply_required" value="1" {% if profile.reply_required %}checked{% endif %}> reply required</label>
<label class="checkbox is-size-7" style="margin-left: 0.6rem;"><input type="checkbox" name="exact_match_only" value="1" {% if profile.exact_match_only %}checked{% endif %}> exact match</label>
</div>
</div>
<label class="label is-size-7">BP Template</label>
<textarea class="textarea is-small" name="template_text" rows="5">{{ profile.template_text }}</textarea>
<div class="buttons" style="margin-top: 0.6rem;">
<button class="button is-link is-small" type="submit">Save Profile</button>
</div>
</form>
<div class="columns">
<div class="column">
<h3 class="title is-7">Channel Bindings</h3>
<table class="table is-fullwidth is-striped is-size-7">
<thead>
<tr><th>Direction</th><th>Service</th><th>Channel</th><th></th></tr>
</thead>
<tbody>
{% for binding in profile.channel_bindings.all %}
<tr>
<td>{{ binding.direction }}</td>
<td>{{ binding.service }}</td>
<td>{{ binding.channel_identifier }}</td>
<td>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="binding_delete">
<input type="hidden" name="binding_id" value="{{ binding.id }}">
<button class="button is-danger is-light is-small" type="submit">Delete</button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="4">No bindings yet.</td></tr>
{% endfor %}
</tbody>
</table>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="binding_create">
<input type="hidden" name="profile_id" value="{{ profile.id }}">
<div class="columns">
<div class="column">
<div class="select is-small is-fullwidth">
<select name="direction">
{% for value in directions %}
<option value="{{ value }}">{{ value }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="column">
<div class="select is-small is-fullwidth">
<select name="service">
{% for value in channel_services %}
<option value="{{ value }}">{{ value }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="column">
<input class="input is-small" name="channel_identifier" placeholder="channel identifier">
</div>
<div class="column is-narrow">
<button class="button is-link is-small" type="submit">Add</button>
</div>
</div>
</form>
</div>
<div class="column">
<h3 class="title is-7">Actions</h3>
<table class="table is-fullwidth is-striped is-size-7">
<thead>
<tr><th>Type</th><th>Enabled</th><th>Position</th><th></th></tr>
</thead>
<tbody>
{% for action_row in profile.actions.all %}
<tr>
<td>{{ action_row.action_type }}</td>
<td>{{ action_row.enabled }}</td>
<td>{{ action_row.position }}</td>
<td>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="action_update">
<input type="hidden" name="command_action_id" value="{{ action_row.id }}">
<label class="checkbox is-size-7"><input type="checkbox" name="enabled" value="1" {% if action_row.enabled %}checked{% endif %}> enabled</label>
<input class="input is-small" style="width: 5rem;" name="position" value="{{ action_row.position }}">
<button class="button is-link is-light is-small" type="submit">Save</button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="4">No actions.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<form method="post" style="margin-top: 0.75rem;">
{% csrf_token %}
<input type="hidden" name="action" value="profile_delete">
<input type="hidden" name="profile_id" value="{{ profile.id }}">
<button class="button is-danger is-light is-small" type="submit">Delete Profile</button>
</form>
</article>
{% empty %}
<article class="notification is-light">No command profiles configured.</article>
{% endfor %}
<article class="box">
<h2 class="title is-6">Business Plan Documents</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead>
<tr><th>Title</th><th>Status</th><th>Source</th><th>Updated</th><th></th></tr>
</thead>
<tbody>
{% for doc in documents %}
<tr>
<td>{{ doc.title }}</td>
<td>{{ doc.status }}</td>
<td>{{ doc.source_service }} · {{ doc.source_channel_identifier }}</td>
<td>{{ doc.updated_at }}</td>
<td><a class="button is-small is-link is-light" href="{% url 'business_plan_editor' doc_id=doc.id %}">Open</a></td>
</tr>
{% empty %}
<tr><td colspan="5">No business plan documents yet.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">Translation Bridges</h2>
<form method="post" style="margin-bottom: 0.75rem;">
{% csrf_token %}
<input type="hidden" name="action" value="bridge_create">
<div class="columns is-multiline">
<div class="column is-2"><input class="input is-small" name="name" placeholder="name"></div>
<div class="column is-2"><input class="input is-small" name="quick_mode_title" placeholder="quick mode: en|es"></div>
<div class="column is-2">
<div class="select is-small is-fullwidth"><select name="a_service">{% for value in channel_services %}<option value="{{ value }}">{{ value }}</option>{% endfor %}</select></div>
</div>
<div class="column is-2"><input class="input is-small" name="a_channel_identifier" placeholder="A channel"></div>
<div class="column is-1"><input class="input is-small" name="a_language" value="en"></div>
<div class="column is-2">
<div class="select is-small is-fullwidth"><select name="b_service">{% for value in channel_services %}<option value="{{ value }}">{{ value }}</option>{% endfor %}</select></div>
</div>
<div class="column is-2"><input class="input is-small" name="b_channel_identifier" placeholder="B channel"></div>
<div class="column is-1"><input class="input is-small" name="b_language" value="es"></div>
<div class="column is-2">
<div class="select is-small is-fullwidth"><select name="direction">{% for value in bridge_directions %}<option value="{{ value }}">{{ value }}</option>{% endfor %}</select></div>
</div>
<div class="column is-1"><button class="button is-link is-small" type="submit">Add</button></div>
</div>
</form>
<table class="table is-fullwidth is-striped is-size-7">
<thead>
<tr><th>Name</th><th>A</th><th>B</th><th>Direction</th><th></th></tr>
</thead>
<tbody>
{% for bridge in bridges %}
<tr>
<td>{{ bridge.name }}</td>
<td>{{ bridge.a_service }} · {{ bridge.a_channel_identifier }} · {{ bridge.a_language }}</td>
<td>{{ bridge.b_service }} · {{ bridge.b_channel_identifier }} · {{ bridge.b_language }}</td>
<td>{{ bridge.direction }}</td>
<td>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="bridge_delete">
<input type="hidden" name="bridge_id" value="{{ bridge.id }}">
<button class="button is-danger is-light is-small" type="submit">Delete</button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="5">No translation bridges configured.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">Translation Event Log</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead>
<tr><th>Bridge</th><th>Status</th><th>Target</th><th>Error</th><th>At</th></tr>
</thead>
<tbody>
{% for event in events %}
<tr>
<td>{{ event.bridge.name }}</td>
<td>{{ event.status }}</td>
<td>{{ event.target_service }} · {{ event.target_channel }}</td>
<td>{{ event.error|default:"-" }}</td>
<td>{{ event.created_at }}</td>
</tr>
{% empty %}
<tr><td colspan="5">No events yet.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div>
</section>
{% endblock %}

View File

@@ -72,6 +72,31 @@
<span class="icon is-small"><i class="fa-solid fa-rotate-right"></i></span> <span class="icon is-small"><i class="fa-solid fa-rotate-right"></i></span>
<span>Force Sync</span> <span>Force Sync</span>
</button> </button>
<div class="compose-command-menu">
<button
type="button"
class="button is-light is-rounded compose-command-menu-toggle"
title="Enable or disable command triggers for this chat">
<span class="icon is-small"><i class="fa-solid fa-diagram-project"></i></span>
<span>Commands</span>
</button>
<div class="compose-command-menu-panel is-hidden">
{% for option in command_options %}
<label class="compose-command-option">
<input
type="checkbox"
class="compose-command-toggle"
data-command-slug="{{ option.slug }}"
{% if option.enabled_here %}checked{% endif %}>
<span class="compose-command-option-title">{{ option.name }}</span>
{% if option.trigger_token %}
<span class="compose-command-option-token">{{ option.trigger_token }}</span>
{% endif %}
</label>
{% endfor %}
<a class="compose-command-settings-link" href="{% url 'command_routing' %}">Open command routing</a>
</div>
</div>
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="drafts"> <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 class="icon is-small"><i class="fa-solid fa-pen"></i></span>
<span>Drafts</span> <span>Drafts</span>
@@ -241,10 +266,11 @@
data-summary-url="{{ compose_summary_url }}" data-summary-url="{{ compose_summary_url }}"
data-quick-insights-url="{{ compose_quick_insights_url }}" data-quick-insights-url="{{ compose_quick_insights_url }}"
data-history-sync-url="{{ compose_history_sync_url }}" data-history-sync-url="{{ compose_history_sync_url }}"
data-toggle-command-url="{{ compose_toggle_command_url }}"
data-engage-preview-url="{{ compose_engage_preview_url }}" data-engage-preview-url="{{ compose_engage_preview_url }}"
data-engage-send-url="{{ compose_engage_send_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 }}" data-message-id="{{ msg.id }}"> <div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}" data-message-id="{{ msg.id }}"{% if msg.reply_to_id %} data-reply-to-id="{{ msg.reply_to_id }}"{% endif %} data-reply-snippet="{{ msg.display_text|default:msg.text|default:''|truncatechars:120|escape }}">
{% if msg.gap_fragments %} {% if msg.gap_fragments %}
{% with gap=msg.gap_fragments.0 %} {% with gap=msg.gap_fragments.0 %}
<p <p
@@ -256,6 +282,11 @@
{% endwith %} {% endwith %}
{% endif %} {% endif %}
<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 %}">
{% if msg.reply_to_id %}
<div class="compose-reply-ref" data-reply-target-id="{{ msg.reply_to_id }}" data-reply-preview="{{ msg.reply_preview|default:''|escape }}">
<button type="button" class="compose-reply-link" title="Jump to referenced message"></button>
</div>
{% endif %}
<div class="compose-source-badge-wrap"> <div class="compose-source-badge-wrap">
<span class="compose-source-badge source-{{ msg.source_service|default:'web'|lower }}">{{ msg.source_label }}</span> <span class="compose-source-badge source-{{ msg.source_service|default:'web'|lower }}">{{ msg.source_label }}</span>
</div> </div>
@@ -336,6 +367,10 @@
</span> </span>
{% endif %} {% endif %}
</p> </p>
<button type="button" class="compose-reply-btn" title="Reply to this message" aria-label="Reply to this message">
<span class="icon is-small"><i class="fa-solid fa-reply"></i></span>
<span class="compose-reply-btn-label">Reply</span>
</button>
</article> </article>
</div> </div>
{% empty %} {% empty %}
@@ -365,6 +400,7 @@
<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="panel_id" value="{{ panel_id }}"> <input type="hidden" name="panel_id" value="{{ panel_id }}">
<input type="hidden" name="reply_to_message_id" value="">
<input type="hidden" name="failsafe_arm" value="0"> <input type="hidden" name="failsafe_arm" value="0">
<input type="hidden" name="failsafe_confirm" value="0"> <input type="hidden" name="failsafe_confirm" value="0">
<div class="compose-send-safety"> <div class="compose-send-safety">
@@ -372,6 +408,11 @@
<input type="checkbox" class="manual-confirm"> Confirm Send <input type="checkbox" class="manual-confirm"> Confirm Send
</label> </label>
</div> </div>
<div id="{{ panel_id }}-reply-banner" class="compose-reply-banner is-hidden">
<span class="compose-reply-banner-label">Replying to:</span>
<span id="{{ panel_id }}-reply-text" class="compose-reply-banner-text"></span>
<button type="button" id="{{ panel_id }}-reply-clear" class="button is-white is-small compose-reply-clear-btn">Clear</button>
</div>
<div class="compose-composer-capsule"> <div class="compose-composer-capsule">
<textarea <textarea
id="{{ panel_id }}-textarea" id="{{ panel_id }}-textarea"
@@ -414,6 +455,13 @@
gap: 0.3rem; gap: 0.3rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
#{{ panel_id }} .compose-row.compose-reply-target {
animation: composeReplyFlash 1.1s ease-out;
}
#{{ panel_id }} .compose-row.compose-reply-selected .compose-bubble {
border-color: rgba(47, 79, 122, 0.6);
box-shadow: 0 0 0 2px rgba(47, 79, 122, 0.12);
}
#{{ panel_id }} .compose-row.is-in { #{{ panel_id }} .compose-row.is-in {
align-items: flex-start; align-items: flex-start;
} }
@@ -478,6 +526,56 @@
padding: 0.52rem 0.62rem; padding: 0.52rem 0.62rem;
box-shadow: none; box-shadow: none;
} }
#{{ panel_id }} .compose-reply-ref {
margin-bottom: 0.28rem;
}
#{{ panel_id }} .compose-reply-link {
border: 0;
background: rgba(31, 41, 55, 0.06);
border-radius: 6px;
padding: 0.12rem 0.42rem;
font-size: 0.72rem;
line-height: 1.2;
color: #3b4b5e;
cursor: pointer;
max-width: 100%;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#{{ panel_id }} .compose-reply-link:hover {
background: rgba(31, 41, 55, 0.1);
}
#{{ panel_id }} .compose-reply-btn {
margin-top: 0.34rem;
border: 0;
background: transparent;
color: #5f6f82;
padding: 0.1rem 0.16rem;
height: 1.4rem;
min-height: 1.4rem;
display: inline-flex;
align-items: center;
gap: 0.15rem;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}
#{{ panel_id }} .compose-reply-btn-label {
font-size: 0.68rem;
line-height: 1;
font-weight: 600;
}
#{{ panel_id }} .compose-row:hover .compose-reply-btn,
#{{ panel_id }} .compose-row.compose-reply-selected .compose-reply-btn,
#{{ panel_id }} .compose-reply-btn:focus-visible {
opacity: 1;
pointer-events: auto;
}
#{{ panel_id }} .compose-reply-btn:hover {
color: #2f4f7a;
}
#{{ panel_id }} .compose-source-badge-wrap { #{{ panel_id }} .compose-source-badge-wrap {
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
@@ -667,6 +765,47 @@
#{{ panel_id }} .compose-platform-select { #{{ panel_id }} .compose-platform-select {
min-width: 11rem; min-width: 11rem;
} }
#{{ panel_id }} .compose-command-menu {
position: relative;
}
#{{ panel_id }} .compose-command-menu-panel {
position: absolute;
right: 0;
top: calc(100% + 0.3rem);
min-width: 14.5rem;
padding: 0.5rem;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.14);
background: #fff;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
z-index: 9;
display: grid;
gap: 0.35rem;
}
#{{ panel_id }} .compose-command-menu-panel.is-hidden {
display: none;
}
#{{ panel_id }} .compose-command-option {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.76rem;
color: #334155;
}
#{{ panel_id }} .compose-command-option-title {
font-weight: 600;
}
#{{ panel_id }} .compose-command-option-token {
margin-left: auto;
color: #64748b;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.68rem;
}
#{{ panel_id }} .compose-command-settings-link {
margin-top: 0.2rem;
font-size: 0.72rem;
color: #2563eb;
}
#{{ panel_id }} .compose-gap-artifacts { #{{ panel_id }} .compose-gap-artifacts {
align-self: center; align-self: center;
width: min(92%, 34rem); width: min(92%, 34rem);
@@ -800,6 +939,38 @@
margin-bottom: 0.45rem; margin-bottom: 0.45rem;
color: #505050; color: #505050;
} }
#{{ panel_id }} .compose-reply-banner {
margin-top: 0.42rem;
margin-bottom: 0.3rem;
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.3rem 0.45rem;
border: 1px solid rgba(47, 79, 122, 0.24);
border-radius: 7px;
background: rgba(238, 246, 255, 0.7);
}
#{{ panel_id }} .compose-reply-banner.is-hidden {
display: none;
}
#{{ panel_id }} .compose-reply-banner-label {
font-size: 0.72rem;
color: #34506f;
font-weight: 700;
}
#{{ panel_id }} .compose-reply-banner-text {
flex: 1 1 auto;
min-width: 0;
font-size: 0.75rem;
color: #213447;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#{{ panel_id }} .compose-reply-clear-btn {
border: 1px solid rgba(47, 79, 122, 0.25);
color: #2f4f7a;
}
#{{ panel_id }} .compose-status { #{{ panel_id }} .compose-status {
margin-top: 0.55rem; margin-top: 0.55rem;
min-height: 1.1rem; min-height: 1.1rem;
@@ -1252,6 +1423,10 @@
50% { transform: translateX(2px); } 50% { transform: translateX(2px); }
75% { transform: translateX(-1px); } 75% { transform: translateX(-1px); }
} }
@keyframes composeReplyFlash {
0% { box-shadow: 0 0 0 0 rgba(47, 79, 122, 0.45); }
100% { box-shadow: 0 0 0 14px rgba(47, 79, 122, 0); }
}
@media (max-width: 768px) { @media (max-width: 768px) {
#{{ panel_id }} .compose-thread { #{{ panel_id }} .compose-thread {
max-height: 52vh; max-height: 52vh;
@@ -1294,6 +1469,10 @@
const hiddenService = document.getElementById(panelId + "-input-service"); const hiddenService = document.getElementById(panelId + "-input-service");
const hiddenIdentifier = document.getElementById(panelId + "-input-identifier"); const hiddenIdentifier = document.getElementById(panelId + "-input-identifier");
const hiddenPerson = document.getElementById(panelId + "-input-person"); const hiddenPerson = document.getElementById(panelId + "-input-person");
const hiddenReplyTo = form.querySelector('input[name="reply_to_message_id"]');
const replyBanner = document.getElementById(panelId + "-reply-banner");
const replyBannerText = document.getElementById(panelId + "-reply-text");
const replyClearBtn = document.getElementById(panelId + "-reply-clear");
const renderMode = "{{ render_mode }}"; const renderMode = "{{ render_mode }}";
if (!thread || !form || !textarea) { if (!thread || !form || !textarea) {
return; return;
@@ -1348,6 +1527,7 @@
lightboxIndex: -1, lightboxIndex: -1,
seenMessageIds: new Set(), seenMessageIds: new Set(),
replyTimingTimer: null, replyTimingTimer: null,
replyTargetId: "",
}; };
window.giaComposePanels[panelId] = panelState; window.giaComposePanels[panelId] = panelState;
const triggerButtons = Array.from(panel.querySelectorAll(".js-ai-trigger")); const triggerButtons = Array.from(panel.querySelectorAll(".js-ai-trigger"));
@@ -1681,6 +1861,80 @@
}); });
}); });
}; };
const bindCommandMenu = function (rootNode) {
const scope = rootNode || panel;
if (!scope) {
return;
}
scope.querySelectorAll(".compose-command-menu").forEach(function (menu) {
if (menu.dataset.bound === "1") {
return;
}
menu.dataset.bound = "1";
const toggleButton = menu.querySelector(".compose-command-menu-toggle");
const menuPanel = menu.querySelector(".compose-command-menu-panel");
if (!toggleButton || !menuPanel) {
return;
}
const closeMenu = function () {
menuPanel.classList.add("is-hidden");
};
const openMenu = function () {
menuPanel.classList.remove("is-hidden");
};
toggleButton.addEventListener("click", function (ev) {
ev.preventDefault();
ev.stopPropagation();
if (menuPanel.classList.contains("is-hidden")) {
openMenu();
} else {
closeMenu();
}
});
document.addEventListener("click", function (ev) {
if (!menu.contains(ev.target)) {
closeMenu();
}
});
menu.querySelectorAll(".compose-command-toggle").forEach(function (checkbox) {
checkbox.addEventListener("change", async function () {
const toggleUrl = String(thread.dataset.toggleCommandUrl || "").trim();
const slug = String(checkbox.dataset.commandSlug || "").trim();
if (!toggleUrl || !slug) {
checkbox.checked = !checkbox.checked;
setStatus("Command toggle endpoint is unavailable.", "warning");
return;
}
const shouldEnable = !!checkbox.checked;
checkbox.disabled = true;
try {
const params = queryParams({
slug: slug,
enabled: shouldEnable ? "1" : "0",
});
const result = await postFormJson(toggleUrl, params);
if (!result.ok) {
checkbox.checked = !shouldEnable;
setStatus(
String(result.message || result.error || "Command update failed."),
String(result.level || "danger")
);
return;
}
setStatus(
String(result.message || (slug + (shouldEnable ? " enabled." : " disabled."))),
"success"
);
} catch (err) {
checkbox.checked = !shouldEnable;
setStatus("Failed to update command binding.", "danger");
} finally {
checkbox.disabled = false;
}
});
});
});
};
const ensureEmptyState = function (messageText) { const ensureEmptyState = function (messageText) {
if (!thread) { if (!thread) {
@@ -2060,6 +2314,12 @@
row.className = "compose-row " + (outgoing ? "is-out" : "is-in"); row.className = "compose-row " + (outgoing ? "is-out" : "is-in");
row.dataset.ts = String(msg.ts || 0); row.dataset.ts = String(msg.ts || 0);
row.dataset.minute = minuteBucketFromTs(msg.ts || 0); row.dataset.minute = minuteBucketFromTs(msg.ts || 0);
row.dataset.replySnippet = normalizeSnippet(
msg.display_text || msg.text || (msg.image_url ? "" : "(no text)")
);
if (msg.reply_to_id) {
row.dataset.replyToId = String(msg.reply_to_id || "");
}
if (messageId) { if (messageId) {
row.dataset.messageId = messageId; row.dataset.messageId = messageId;
panelState.seenMessageIds.add(messageId); panelState.seenMessageIds.add(messageId);
@@ -2069,6 +2329,19 @@
const bubble = document.createElement("article"); const bubble = document.createElement("article");
bubble.className = "compose-bubble " + (outgoing ? "is-out" : "is-in"); bubble.className = "compose-bubble " + (outgoing ? "is-out" : "is-in");
if (msg.reply_to_id) {
const replyRef = document.createElement("div");
replyRef.className = "compose-reply-ref";
replyRef.dataset.replyTargetId = String(msg.reply_to_id || "");
replyRef.dataset.replyPreview = String(msg.reply_preview || "");
const link = document.createElement("button");
link.type = "button";
link.className = "compose-reply-link";
link.title = "Jump to referenced message";
replyRef.appendChild(link);
bubble.appendChild(replyRef);
}
// Add source badge for client-side rendered messages // Add source badge for client-side rendered messages
if (msg.source_label) { if (msg.source_label) {
const badgeWrap = document.createElement("div"); const badgeWrap = document.createElement("div");
@@ -2178,6 +2451,14 @@
meta.appendChild(tickWrap); meta.appendChild(tickWrap);
} }
bubble.appendChild(meta); bubble.appendChild(meta);
const replyBtn = document.createElement("button");
replyBtn.type = "button";
replyBtn.className = "compose-reply-btn";
replyBtn.title = "Reply to this message";
replyBtn.setAttribute("aria-label", "Reply to this message");
replyBtn.innerHTML =
'<span class="icon is-small"><i class="fa-solid fa-reply"></i></span><span class="compose-reply-btn-label">Reply</span>';
bubble.appendChild(replyBtn);
// If message carries receipt metadata, append dataset so the popover can use it. // If message carries receipt metadata, append dataset so the popover can use it.
if (msg.receipt_payload || msg.read_source_service || msg.read_by_identifier) { if (msg.receipt_payload || msg.read_source_service || msg.read_by_identifier) {
@@ -2202,6 +2483,7 @@
row.appendChild(bubble); row.appendChild(bubble);
insertRowByTs(row); insertRowByTs(row);
wireImageFallbacks(row); wireImageFallbacks(row);
bindReplyReferences(row);
updateGlanceFromMessage(msg); updateGlanceFromMessage(msg);
}; };
@@ -2261,6 +2543,19 @@
// Delegate click on tick triggers inside thread // Delegate click on tick triggers inside thread
thread.addEventListener("click", function (ev) { thread.addEventListener("click", function (ev) {
const replyBtn = ev.target.closest && ev.target.closest(".compose-reply-btn");
if (replyBtn) {
const row = replyBtn.closest(".compose-row");
if (!row) {
return;
}
const targetId = String(row.dataset.messageId || "").trim();
setReplyTarget(targetId, row.dataset.replySnippet || "");
if (textarea) {
textarea.focus();
}
return;
}
const btn = ev.target.closest && ev.target.closest('.js-receipt-trigger'); const btn = ev.target.closest && ev.target.closest('.js-receipt-trigger');
if (!btn) return; if (!btn) return;
if (activeReceiptBtn === btn && !receiptPopover.classList.contains('is-hidden')) { if (activeReceiptBtn === btn && !receiptPopover.classList.contains('is-hidden')) {
@@ -2456,6 +2751,7 @@
} }
applyMinuteGrouping(); applyMinuteGrouping();
bindHistorySyncButtons(panel); bindHistorySyncButtons(panel);
bindCommandMenu(panel);
const setStatus = function (message, level) { const setStatus = function (message, level) {
if (!statusBox) { if (!statusBox) {
@@ -2551,6 +2847,135 @@
return params; return params;
}; };
const normalizeSnippet = function (value) {
const compact = String(value || "").replace(/\s+/g, " ").trim();
if (!compact) {
return "(no text)";
}
if (compact.length <= 120) {
return compact;
}
return compact.slice(0, 117).trimEnd() + "...";
};
const rowByMessageId = function (messageId) {
const key = String(messageId || "").trim();
if (!key) {
return null;
}
return thread.querySelector('.compose-row[data-message-id="' + key + '"]');
};
const flashReplyTarget = function (row) {
if (!row) {
return;
}
row.classList.remove("compose-reply-target");
void row.offsetWidth;
row.classList.add("compose-reply-target");
window.setTimeout(function () {
row.classList.remove("compose-reply-target");
}, 1200);
};
const clearReplySelectionClass = function () {
thread.querySelectorAll(".compose-row.compose-reply-selected").forEach(function (row) {
row.classList.remove("compose-reply-selected");
});
};
const clearReplyTarget = function () {
panelState.replyTargetId = "";
if (hiddenReplyTo) {
hiddenReplyTo.value = "";
}
if (replyBanner) {
replyBanner.classList.add("is-hidden");
}
if (replyBannerText) {
replyBannerText.textContent = "";
}
clearReplySelectionClass();
};
const setReplyTarget = function (messageId, explicitPreview) {
const key = String(messageId || "").trim();
if (!key) {
clearReplyTarget();
return;
}
const row = rowByMessageId(key);
let preview = normalizeSnippet(explicitPreview || "");
if (row) {
const rowSnippet = normalizeSnippet(row.dataset.replySnippet || "");
if (rowSnippet && rowSnippet !== "(no text)") {
preview = rowSnippet;
}
}
panelState.replyTargetId = key;
if (hiddenReplyTo) {
hiddenReplyTo.value = key;
}
if (replyBannerText) {
replyBannerText.textContent = preview;
}
if (replyBanner) {
replyBanner.classList.remove("is-hidden");
}
clearReplySelectionClass();
if (row) {
row.classList.add("compose-reply-selected");
}
};
const bindReplyReferences = function (rootNode) {
const scope = rootNode || thread;
if (!scope) {
return;
}
scope.querySelectorAll(".compose-row").forEach(function (row) {
if (!row.dataset.replySnippet) {
const body = row.querySelector(".compose-body");
if (body) {
row.dataset.replySnippet = normalizeSnippet(body.textContent || "");
}
}
});
scope.querySelectorAll(".compose-reply-ref").forEach(function (ref) {
const button = ref.querySelector(".compose-reply-link");
const targetId = String(ref.dataset.replyTargetId || "").trim();
if (!button || !targetId) {
return;
}
const targetRow = rowByMessageId(targetId);
const inferredPreview = targetRow
? normalizeSnippet(targetRow.dataset.replySnippet || "")
: normalizeSnippet(ref.dataset.replyPreview || "");
button.textContent = "Reply to: " + inferredPreview;
if (button.dataset.bound === "1") {
return;
}
button.dataset.bound = "1";
button.addEventListener("click", function () {
const row = rowByMessageId(targetId);
if (!row) {
return;
}
row.scrollIntoView({ behavior: "smooth", block: "center" });
flashReplyTarget(row);
});
});
};
bindReplyReferences(panel);
if (replyClearBtn) {
replyClearBtn.addEventListener("click", function () {
clearReplyTarget();
if (textarea) {
textarea.focus();
}
});
}
const postFormJson = async function (url, params) { const postFormJson = async function (url, params) {
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
@@ -2619,6 +3044,7 @@
if (metaLine) { if (metaLine) {
metaLine.textContent = titleCase(service) + " · " + identifier; metaLine.textContent = titleCase(service) + " · " + identifier;
} }
clearReplyTarget();
if (panelState.socket) { if (panelState.socket) {
try { try {
panelState.socket.close(); panelState.socket.close();
@@ -3468,6 +3894,7 @@
if (result.ok) { if (result.ok) {
setStatus('', 'success'); setStatus('', 'success');
textarea.value = ''; textarea.value = '';
clearReplyTarget();
autosize(); autosize();
flashCompose('is-send-success'); flashCompose('is-send-success');
poll(true); poll(true);
@@ -3552,6 +3979,7 @@
flashCompose("is-send-success"); flashCompose("is-send-success");
setStatus("", "success"); setStatus("", "success");
textarea.value = ""; textarea.value = "";
clearReplyTarget();
autosize(); autosize();
poll(true); poll(true);
} else { } else {

View File

@@ -0,0 +1,138 @@
from __future__ import annotations
from unittest.mock import AsyncMock, patch
from asgiref.sync import async_to_sync
from django.test import TransactionTestCase
from core.commands.base import CommandContext
from core.commands.handlers.bp import BPCommandHandler
from core.models import (
AI,
BusinessPlanDocument,
ChatSession,
CommandAction,
CommandChannelBinding,
CommandProfile,
CommandRun,
Message,
Person,
PersonIdentifier,
User,
)
class BPFallbackTests(TransactionTestCase):
def setUp(self):
self.user = User.objects.create_user(
username="bp-fallback-user",
email="bp-fallback@example.com",
password="x",
)
self.person = Person.objects.create(user=self.user, name="Fallback Person")
self.identifier = PersonIdentifier.objects.create(
user=self.user,
person=self.person,
service="whatsapp",
identifier="120363402761690215",
)
self.session = ChatSession.objects.create(
user=self.user,
identifier=self.identifier,
)
self.profile = CommandProfile.objects.create(
user=self.user,
slug="bp",
name="Business Plan",
enabled=True,
trigger_token="#bp#",
reply_required=True,
exact_match_only=True,
)
CommandChannelBinding.objects.create(
profile=self.profile,
direction="ingress",
service="whatsapp",
channel_identifier="120363402761690215",
enabled=True,
)
CommandChannelBinding.objects.create(
profile=self.profile,
direction="egress",
service="web",
channel_identifier="120363402761690215",
enabled=True,
)
for action_type, position in (
("extract_bp", 0),
("save_document", 1),
("post_result", 2),
):
CommandAction.objects.create(
profile=self.profile,
action_type=action_type,
enabled=True,
position=position,
)
AI.objects.create(
user=self.user,
base_url="https://example.invalid",
api_key="test-key",
model="gpt-4o-mini",
)
def test_bp_falls_back_to_draft_when_ai_fails(self):
anchor = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="peer",
text="Anchor content",
ts=1000,
source_service="whatsapp",
source_message_id="wa-anchor-1",
)
trigger = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="peer",
text="#bp#",
ts=2000,
source_service="whatsapp",
source_message_id="wa-trigger-1",
reply_to=anchor,
reply_source_service="whatsapp",
reply_source_message_id="wa-anchor-1",
)
with patch(
"core.commands.handlers.bp.ai_runner.run_prompt",
new=AsyncMock(side_effect=RuntimeError("quota")),
):
result = async_to_sync(BPCommandHandler().execute)(
CommandContext(
service="whatsapp",
channel_identifier="120363402761690215",
message_id=str(trigger.id),
user_id=self.user.id,
message_text="#bp#",
payload={},
)
)
self.assertTrue(result.ok)
run = CommandRun.objects.get(trigger_message=trigger, profile=self.profile)
self.assertEqual("ok", run.status)
self.assertIn("bp_ai_failed", str(run.error))
self.assertTrue(BusinessPlanDocument.objects.filter(trigger_message=trigger).exists())
def test_bp_uses_same_ai_selection_order_as_compose(self):
AI.objects.create(
user=self.user,
base_url="https://example.invalid",
api_key="another-key",
model="gpt-4o",
)
selected = AI.objects.filter(user=self.user).first()
# Compose uses QuerySet.first() without explicit ordering; BP should match.
self.assertIsNotNone(selected)
self.assertEqual(self.profile.user_id, selected.user_id)

View File

@@ -0,0 +1,241 @@
from __future__ import annotations
from asgiref.sync import async_to_sync
from django.test import TestCase
from core.commands.base import CommandContext
from core.commands.engine import _matches_trigger, process_inbound_message
from core.messaging.reply_sync import extract_reply_ref, resolve_reply_target
from core.models import (
ChatSession,
CommandChannelBinding,
CommandProfile,
Message,
Person,
PersonIdentifier,
User,
)
class Phase1ReplyResolutionTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="phase1-reply-user",
email="phase1-reply@example.com",
password="x",
)
self.person = Person.objects.create(user=self.user, name="Reply Person")
self.identifier = PersonIdentifier.objects.create(
user=self.user,
person=self.person,
service="signal",
identifier="+15550000001",
)
self.session = ChatSession.objects.create(
user=self.user,
identifier=self.identifier,
)
def test_resolve_reply_target_by_source_message_id(self):
anchor = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="+15550000001",
text="anchor",
ts=1000,
source_service="signal",
source_message_id="signal-msg-1",
)
resolved = async_to_sync(resolve_reply_target)(
self.user,
self.session,
{
"reply_source_service": "signal",
"reply_source_message_id": "signal-msg-1",
},
)
self.assertEqual(anchor.id, resolved.id if resolved else None)
def test_resolve_reply_target_with_bridge_ref_fallback(self):
anchor = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="+15550000001",
text="anchor",
ts=2000,
receipt_payload={
"bridge_refs": {
"signal": [
{
"xmpp_message_id": "xmpp-bridge-1",
"upstream_message_id": "signal-upstream-1",
"upstream_author": "+15550000001",
"upstream_ts": 2000,
"updated_at": 2000,
}
]
}
},
)
resolved = async_to_sync(resolve_reply_target)(
self.user,
self.session,
{
"reply_source_service": "signal",
"reply_source_message_id": "signal-upstream-1",
},
)
self.assertEqual(anchor.id, resolved.id if resolved else None)
def test_resolve_reply_target_miss(self):
resolved = async_to_sync(resolve_reply_target)(
self.user,
self.session,
{
"reply_source_service": "signal",
"reply_source_message_id": "does-not-exist",
},
)
self.assertIsNone(resolved)
def test_extract_reply_ref_xmpp(self):
result = extract_reply_ref(
"xmpp",
{
"reply_source_message_id": "xmpp-msg-1",
"reply_source_chat_id": "alice@example.test",
},
)
self.assertEqual("xmpp-msg-1", result.get("reply_source_message_id"))
self.assertEqual("xmpp", result.get("reply_source_service"))
def test_extract_reply_ref_signal(self):
result = extract_reply_ref(
"signal",
{
"envelope": {
"dataMessage": {
"quote": {"id": "signal-msg-quoted"},
}
}
},
)
self.assertEqual("signal-msg-quoted", result.get("reply_source_message_id"))
self.assertEqual("signal", result.get("reply_source_service"))
def test_extract_reply_ref_whatsapp(self):
result = extract_reply_ref(
"whatsapp",
{
"extendedTextMessage": {
"contextInfo": {
"stanzaId": "wa-msg-quoted",
"participant": "12345@s.whatsapp.net",
}
}
},
)
self.assertEqual("wa-msg-quoted", result.get("reply_source_message_id"))
self.assertEqual("whatsapp", result.get("reply_source_service"))
def test_extract_reply_ref_whatsapp_stanza_id_variant(self):
result = extract_reply_ref(
"whatsapp",
{
"extendedTextMessage": {
"contextInfo": {
"stanzaID": "wa-msg-quoted-2",
}
}
},
)
self.assertEqual("wa-msg-quoted-2", result.get("reply_source_message_id"))
self.assertEqual("whatsapp", result.get("reply_source_service"))
class Phase1CommandEngineTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="phase1-command-user",
email="phase1-command@example.com",
password="x",
)
self.person = Person.objects.create(user=self.user, name="Command Person")
self.identifier = PersonIdentifier.objects.create(
user=self.user,
person=self.person,
service="signal",
identifier="+15550000002",
)
self.session = ChatSession.objects.create(
user=self.user,
identifier=self.identifier,
)
self.profile = CommandProfile.objects.create(
user=self.user,
slug="bp",
name="Business Plan",
enabled=True,
trigger_token="#bp#",
reply_required=True,
exact_match_only=True,
)
CommandChannelBinding.objects.create(
profile=self.profile,
direction="ingress",
service="web",
channel_identifier="web-chan-1",
enabled=True,
)
def test_matches_trigger_exact_only(self):
self.assertTrue(_matches_trigger(self.profile, "#bp#"))
self.assertFalse(_matches_trigger(self.profile, " #bp# extra "))
def test_process_inbound_message_requires_reply(self):
msg = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="",
text="#bp#",
ts=3000,
source_service="web",
source_chat_id="web-chan-1",
message_meta={},
)
results = async_to_sync(process_inbound_message)(
CommandContext(
service="web",
channel_identifier="web-chan-1",
message_id=str(msg.id),
user_id=self.user.id,
message_text="#bp#",
payload={},
)
)
self.assertEqual(1, len(results))
self.assertEqual("skipped", results[0].status)
self.assertEqual("reply_required", results[0].error)
def test_process_inbound_message_skips_mirrored_origin(self):
msg = Message.objects.create(
user=self.user,
session=self.session,
sender_uuid="",
text="#bp#",
ts=4000,
source_service="web",
source_chat_id="web-chan-1",
message_meta={"origin_tag": "translation:test"},
)
results = async_to_sync(process_inbound_message)(
CommandContext(
service="web",
channel_identifier="web-chan-1",
message_id=str(msg.id),
user_id=self.user.id,
message_text="#bp#",
payload={},
)
)
self.assertEqual([], results)

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
import asyncio
from django.test import TestCase
from core.clients import transport
from core.clients.whatsapp import WhatsAppClient
class WhatsAppSendRoutingTests(TestCase):
def setUp(self):
self.loop = asyncio.new_event_loop()
self.client = WhatsAppClient(ur=None, loop=self.loop)
def tearDown(self):
try:
self.loop.close()
except Exception:
pass
def test_to_jid_prefers_known_group_mapping(self):
transport.update_runtime_state(
"whatsapp",
groups=[
{
"identifier": "120363402761690215",
"jid": "120363402761690215@g.us",
}
],
)
jid = self.client._to_jid("120363402761690215")
self.assertEqual("120363402761690215@g.us", jid)
def test_to_jid_keeps_phone_number_for_direct_chat(self):
transport.update_runtime_state("whatsapp", groups=[])
jid = self.client._to_jid("+14155551212")
self.assertEqual("14155551212@s.whatsapp.net", jid)

View File

145
core/translation/engine.py Normal file
View File

@@ -0,0 +1,145 @@
from __future__ import annotations
import hashlib
import time
from asgiref.sync import sync_to_async
from core.clients import transport
from core.messaging import ai as ai_runner
from core.messaging.reply_sync import apply_sync_origin, is_mirrored_origin
from core.models import AI, Message, TranslationBridge, TranslationEventLog
from core.util import logs
log = logs.get_logger("translation_engine")
def _direction_allowed(bridge: TranslationBridge, source_side: str) -> bool:
if bridge.direction == "bidirectional":
return True
if source_side == "a" and bridge.direction == "a_to_b":
return True
if source_side == "b" and bridge.direction == "b_to_a":
return True
return False
def _target_for_side(bridge: TranslationBridge, source_side: str):
if source_side == "a":
return ("b", bridge.b_service, bridge.b_channel_identifier, bridge.b_language)
return ("a", bridge.a_service, bridge.a_channel_identifier, bridge.a_language)
def _source_language(bridge: TranslationBridge, source_side: str):
return bridge.a_language if source_side == "a" else bridge.b_language
async def _translate_text(user, text: str, source_lang: str, target_lang: str) -> str:
ai_obj = await sync_to_async(lambda: AI.objects.filter(user=user).first())()
if ai_obj is None:
return text
prompt = [
{
"role": "system",
"content": (
"Translate the user text exactly for meaning and tone. "
"Do not add commentary. Return only translated text."
),
},
{
"role": "user",
"content": (
f"Source language: {source_lang}\n"
f"Target language: {target_lang}\n"
f"Text:\n{text}"
),
},
]
return str(await ai_runner.run_prompt(prompt, ai_obj) or "").strip()
async def process_inbound_translation(message: Message):
if message is None or not str(message.text or "").strip():
return
if is_mirrored_origin(message.message_meta):
return
source_service = str(message.source_service or "").strip().lower()
source_channel = str(message.source_chat_id or "").strip()
if not source_service or not source_channel:
return
bridges = await sync_to_async(list)(
TranslationBridge.objects.filter(
user=message.user,
enabled=True,
)
)
for bridge in bridges:
side = None
if (
bridge.a_service == source_service
and str(bridge.a_channel_identifier or "").strip() == source_channel
):
side = "a"
elif (
bridge.b_service == source_service
and str(bridge.b_channel_identifier or "").strip() == source_channel
):
side = "b"
if side is None or not _direction_allowed(bridge, side):
continue
_, target_service, target_channel, target_lang = _target_for_side(bridge, side)
source_lang = _source_language(bridge, side)
origin_tag = f"translation:{bridge.id}:{message.id}"
content_hash = hashlib.sha1(
f"{source_service}|{source_channel}|{message.text}".encode("utf-8")
).hexdigest()
log_row = await sync_to_async(TranslationEventLog.objects.create)(
bridge=bridge,
source_message=message,
target_service=target_service,
target_channel=target_channel,
status="pending",
origin_tag=origin_tag,
content_hash=content_hash,
)
try:
translated = await _translate_text(
message.user,
str(message.text or ""),
source_lang=source_lang,
target_lang=target_lang,
)
if target_service != "web":
await transport.send_message_raw(
target_service,
target_channel,
text=translated,
attachments=[],
metadata={"origin_tag": origin_tag},
)
log_row.status = "ok"
log_row.error = ""
except Exception as exc:
log_row.status = "failed"
log_row.error = str(exc)
log.warning("translation forward failed bridge=%s: %s", bridge.id, exc)
await sync_to_async(log_row.save)(update_fields=["status", "error", "updated_at"])
def apply_translation_origin(meta: dict | None, origin_tag: str) -> dict:
return apply_sync_origin(meta, origin_tag)
def parse_quick_mode_title(raw_title: str) -> dict:
title = str(raw_title or "").strip()
parts = [part.strip() for part in title.split("|") if part.strip()]
if len(parts) < 2:
return {}
return {
"a_language": parts[0].lower(),
"b_language": parts[1].lower(),
}

249
core/views/automation.py Normal file
View File

@@ -0,0 +1,249 @@
from __future__ import annotations
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views import View
from core.models import (
BusinessPlanDocument,
BusinessPlanRevision,
CommandAction,
CommandChannelBinding,
CommandProfile,
TranslationBridge,
TranslationEventLog,
)
from core.translation.engine import parse_quick_mode_title
class CommandRoutingSettings(LoginRequiredMixin, View):
template_name = "pages/command-routing.html"
def _context(self, request):
profiles = (
CommandProfile.objects.filter(user=request.user)
.prefetch_related("channel_bindings", "actions")
.order_by("slug")
)
documents = BusinessPlanDocument.objects.filter(user=request.user).order_by(
"-updated_at"
)[:30]
bridges = TranslationBridge.objects.filter(user=request.user).order_by("-id")
events = (
TranslationEventLog.objects.filter(bridge__user=request.user)
.select_related("bridge")
.order_by("-created_at")[:50]
)
return {
"profiles": profiles,
"documents": documents,
"bridges": bridges,
"events": events,
"channel_services": ("web", "xmpp", "signal", "whatsapp"),
"directions": ("ingress", "egress", "scratchpad_mirror"),
"action_types": ("extract_bp", "post_result", "save_document"),
"bridge_directions": ("a_to_b", "b_to_a", "bidirectional"),
}
def get(self, request):
return render(request, self.template_name, self._context(request))
def post(self, request):
action = str(request.POST.get("action") or "").strip()
if action == "profile_create":
slug = str(request.POST.get("slug") or "bp").strip().lower() or "bp"
profile, _ = CommandProfile.objects.get_or_create(
user=request.user,
slug=slug,
defaults={
"name": str(request.POST.get("name") or "Business Plan").strip()
or "Business Plan",
"enabled": True,
"trigger_token": str(
request.POST.get("trigger_token") or "#bp#"
).strip()
or "#bp#",
"template_text": str(request.POST.get("template_text") or ""),
},
)
CommandAction.objects.get_or_create(
profile=profile,
action_type="extract_bp",
defaults={"enabled": True, "position": 0},
)
CommandAction.objects.get_or_create(
profile=profile,
action_type="save_document",
defaults={"enabled": True, "position": 1},
)
CommandAction.objects.get_or_create(
profile=profile,
action_type="post_result",
defaults={"enabled": True, "position": 2},
)
return redirect("command_routing")
if action == "profile_update":
profile = get_object_or_404(
CommandProfile,
id=request.POST.get("profile_id"),
user=request.user,
)
profile.name = str(request.POST.get("name") or profile.name).strip()
profile.enabled = bool(request.POST.get("enabled"))
profile.trigger_token = (
str(request.POST.get("trigger_token") or profile.trigger_token).strip()
or "#bp#"
)
profile.reply_required = bool(request.POST.get("reply_required"))
profile.exact_match_only = bool(request.POST.get("exact_match_only"))
profile.template_text = str(request.POST.get("template_text") or "")
profile.visibility_mode = (
str(request.POST.get("visibility_mode") or "status_in_source").strip()
or "status_in_source"
)
profile.save()
return redirect("command_routing")
if action == "profile_delete":
profile = get_object_or_404(
CommandProfile,
id=request.POST.get("profile_id"),
user=request.user,
)
profile.delete()
return redirect("command_routing")
if action == "binding_create":
profile = get_object_or_404(
CommandProfile,
id=request.POST.get("profile_id"),
user=request.user,
)
CommandChannelBinding.objects.create(
profile=profile,
direction=str(request.POST.get("direction") or "ingress").strip(),
service=str(request.POST.get("service") or "web").strip(),
channel_identifier=str(
request.POST.get("channel_identifier") or ""
).strip(),
enabled=bool(request.POST.get("enabled") or "1"),
)
return redirect("command_routing")
if action == "binding_delete":
binding = get_object_or_404(
CommandChannelBinding,
id=request.POST.get("binding_id"),
profile__user=request.user,
)
binding.delete()
return redirect("command_routing")
if action == "action_update":
row = get_object_or_404(
CommandAction,
id=request.POST.get("command_action_id"),
profile__user=request.user,
)
row.enabled = bool(request.POST.get("enabled"))
row.position = int(request.POST.get("position") or 0)
row.save()
return redirect("command_routing")
if action == "bridge_create":
quick_title = str(request.POST.get("quick_mode_title") or "").strip()
inferred = parse_quick_mode_title(quick_title)
TranslationBridge.objects.create(
user=request.user,
name=str(request.POST.get("name") or "Translation Bridge").strip()
or "Translation Bridge",
enabled=bool(request.POST.get("enabled") or "1"),
a_service=str(request.POST.get("a_service") or "web").strip(),
a_channel_identifier=str(
request.POST.get("a_channel_identifier") or ""
).strip(),
a_language=str(
request.POST.get("a_language")
or inferred.get("a_language")
or "en"
).strip(),
b_service=str(request.POST.get("b_service") or "web").strip(),
b_channel_identifier=str(
request.POST.get("b_channel_identifier") or ""
).strip(),
b_language=str(
request.POST.get("b_language")
or inferred.get("b_language")
or "en"
).strip(),
direction=str(request.POST.get("direction") or "bidirectional").strip(),
quick_mode_title=quick_title,
settings={},
)
return redirect("command_routing")
if action == "bridge_delete":
bridge = get_object_or_404(
TranslationBridge, id=request.POST.get("bridge_id"), user=request.user
)
bridge.delete()
return redirect("command_routing")
return redirect("command_routing")
class BusinessPlanEditor(LoginRequiredMixin, View):
template_name = "pages/business-plan-editor.html"
def get(self, request, doc_id):
document = get_object_or_404(BusinessPlanDocument, id=doc_id, user=request.user)
revisions = document.revisions.order_by("-created_at")[:100]
return render(
request,
self.template_name,
{
"document": document,
"revisions": revisions,
},
)
def post(self, request, doc_id):
document = get_object_or_404(BusinessPlanDocument, id=doc_id, user=request.user)
document.title = str(request.POST.get("title") or document.title).strip()
document.status = str(request.POST.get("status") or document.status).strip()
document.content_markdown = str(request.POST.get("content_markdown") or "")
document.save()
BusinessPlanRevision.objects.create(
document=document,
editor_user=request.user,
content_markdown=document.content_markdown,
structured_payload=document.structured_payload or {},
)
return redirect("business_plan_editor", doc_id=str(document.id))
class TranslationPreview(LoginRequiredMixin, View):
def post(self, request):
bridge = get_object_or_404(
TranslationBridge,
id=request.POST.get("bridge_id"),
user=request.user,
)
source = str(request.POST.get("source") or "").strip().lower()
text = str(request.POST.get("text") or "")
if source == "a":
target_language = bridge.b_language
else:
target_language = bridge.a_language
return JsonResponse(
{
"ok": True,
"bridge_id": str(bridge.id),
"target_language": target_language,
"preview": text,
"note": "Preview endpoint is non-mutating; final translation occurs in runtime sync.",
}
)

View File

@@ -26,6 +26,8 @@ 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.commands.base import CommandContext
from core.commands.engine import process_inbound_message
from core.messaging import ai as ai_runner from core.messaging import ai as ai_runner
from core.messaging import media_bridge from core.messaging import media_bridge
from core.messaging.utils import messages_to_string from core.messaging.utils import messages_to_string
@@ -33,6 +35,9 @@ from core.models import (
AI, AI,
Chat, Chat,
ChatSession, ChatSession,
CommandAction,
CommandChannelBinding,
CommandProfile,
Message, Message,
MessageEvent, MessageEvent,
PatternMitigationPlan, PatternMitigationPlan,
@@ -42,6 +47,7 @@ from core.models import (
WorkspaceConversation, WorkspaceConversation,
) )
from core.realtime.typing_state import get_person_typing_state from core.realtime.typing_state import get_person_typing_state
from core.translation.engine import process_inbound_translation
from core.views.workspace import ( from core.views.workspace import (
INSIGHT_METRICS, INSIGHT_METRICS,
_build_engage_payload, _build_engage_payload,
@@ -466,6 +472,18 @@ def _serialize_message(msg: Message) -> dict:
} }
) )
reply_preview = ""
try:
reply_obj = getattr(msg, "reply_to", None)
if reply_obj is not None:
reply_preview = str(getattr(reply_obj, "text", "") or "").strip()
except Exception:
reply_preview = ""
if reply_preview:
reply_preview = re.sub(r"\s+", " ", reply_preview).strip()
if len(reply_preview) > 140:
reply_preview = reply_preview[:137].rstrip() + "..."
return { return {
"id": str(msg.id), "id": str(msg.id),
"ts": int(msg.ts or 0), "ts": int(msg.ts or 0),
@@ -491,6 +509,15 @@ def _serialize_message(msg: Message) -> dict:
"read_source_service": read_source_service, "read_source_service": read_source_service,
"read_by_identifier": read_by_identifier, "read_by_identifier": read_by_identifier,
"reactions": reaction_rows, "reactions": reaction_rows,
"source_message_id": str(getattr(msg, "source_message_id", "") or ""),
"reply_to_id": str(getattr(msg, "reply_to_id", "") or ""),
"reply_source_message_id": str(
getattr(msg, "reply_source_message_id", "") or ""
),
"reply_preview": reply_preview,
"message_meta": {
"origin_tag": str((getattr(msg, "message_meta", {}) or {}).get("origin_tag") or "")
},
} }
@@ -1498,6 +1525,16 @@ def _context_base(user, service, identifier, person):
service = person_identifier.service service = person_identifier.service
identifier = person_identifier.identifier identifier = person_identifier.identifier
person = person_identifier.person person = person_identifier.person
if service == "whatsapp" and identifier and "@" not in str(identifier):
bare_id = str(identifier).split("@", 1)[0].strip()
group_link = PlatformChatLink.objects.filter(
user=user,
service="whatsapp",
chat_identifier=bare_id,
is_group=True,
).first()
if group_link is not None:
identifier = str(group_link.chat_jid or f"{bare_id}@g.us")
if person_identifier is None and identifier: if person_identifier is None and identifier:
bare_id = identifier.split("@", 1)[0].strip() bare_id = identifier.split("@", 1)[0].strip()
@@ -1527,6 +1564,208 @@ def _context_base(user, service, identifier, person):
} }
def _latest_whatsapp_bridge_ref(message: Message | None) -> dict:
if message is None:
return {}
payload = dict(getattr(message, "receipt_payload", {}) or {})
refs = dict(payload.get("bridge_refs") or {})
rows = list(refs.get("whatsapp") or [])
best = {}
best_updated = -1
for row in rows:
if not isinstance(row, dict):
continue
upstream_id = str(row.get("upstream_message_id") or "").strip()
if not upstream_id:
continue
updated_at = int(row.get("updated_at") or 0)
if updated_at >= best_updated:
best = dict(row)
best_updated = updated_at
return best
def _build_whatsapp_reply_metadata(reply_to: Message | None, channel_identifier: str) -> dict:
if reply_to is None:
return {}
target_message_id = ""
participant = ""
source_service = str(getattr(reply_to, "source_service", "") or "").strip().lower()
if source_service == "whatsapp":
target_message_id = str(getattr(reply_to, "source_message_id", "") or "").strip()
participant = str(getattr(reply_to, "sender_uuid", "") or "").strip()
if not target_message_id:
bridge_ref = _latest_whatsapp_bridge_ref(reply_to)
target_message_id = str(bridge_ref.get("upstream_message_id") or "").strip()
participant = participant or str(bridge_ref.get("upstream_author") or "").strip()
if not target_message_id:
return {}
remote_jid = str(channel_identifier or "").strip()
if "@" not in remote_jid and remote_jid:
remote_jid = f"{remote_jid}@g.us"
return {
"reply_to_upstream_message_id": target_message_id,
"reply_to_participant": participant,
"reply_to_remote_jid": remote_jid,
}
def _canonical_command_channel_identifier(service: str, identifier: str) -> str:
value = str(identifier or "").strip()
if not value:
return ""
if service == "whatsapp":
return value.split("@", 1)[0].strip()
return value
def _command_channel_identifier_variants(service: str, identifier: str) -> set[str]:
canonical = _canonical_command_channel_identifier(service, identifier)
if not canonical:
return set()
variants = {canonical}
if service == "whatsapp":
variants.add(f"{canonical}@g.us")
return variants
def _ensure_bp_profile_and_actions(user) -> CommandProfile:
profile, _ = CommandProfile.objects.get_or_create(
user=user,
slug="bp",
defaults={
"name": "Business Plan",
"enabled": True,
"trigger_token": "#bp#",
"reply_required": True,
"exact_match_only": True,
"window_scope": "conversation",
"visibility_mode": "status_in_source",
},
)
if not profile.enabled:
profile.enabled = True
profile.save(update_fields=["enabled", "updated_at"])
for action_type, position in (
("extract_bp", 0),
("save_document", 1),
("post_result", 2),
):
row, created = CommandAction.objects.get_or_create(
profile=profile,
action_type=action_type,
defaults={"enabled": True, "position": position},
)
if (not created) and (not row.enabled):
row.enabled = True
row.save(update_fields=["enabled", "updated_at"])
return profile
def _toggle_command_for_channel(
*,
user,
service: str,
identifier: str,
slug: str,
enabled: bool,
) -> tuple[bool, str]:
service_key = _default_service(service)
canonical_identifier = _canonical_command_channel_identifier(service_key, identifier)
if not canonical_identifier:
return (False, "missing_identifier")
if slug == "bp":
profile = _ensure_bp_profile_and_actions(user)
else:
profile = (
CommandProfile.objects.filter(user=user, slug=slug)
.order_by("id")
.first()
)
if profile is None:
return (False, f"unknown_command:{slug}")
if not profile.enabled and enabled:
profile.enabled = True
profile.save(update_fields=["enabled", "updated_at"])
variants = _command_channel_identifier_variants(service_key, canonical_identifier)
if not variants:
return (False, "missing_identifier")
if enabled:
for direction in ("ingress", "egress"):
row, _ = CommandChannelBinding.objects.get_or_create(
profile=profile,
direction=direction,
service=service_key,
channel_identifier=canonical_identifier,
defaults={"enabled": True},
)
if not row.enabled:
row.enabled = True
row.save(update_fields=["enabled", "updated_at"])
CommandChannelBinding.objects.filter(
profile=profile,
direction=direction,
service=service_key,
channel_identifier__in=list(variants - {canonical_identifier}),
).update(enabled=False)
else:
CommandChannelBinding.objects.filter(
profile=profile,
direction__in=("ingress", "egress"),
service=service_key,
channel_identifier__in=list(variants),
).update(enabled=False)
return (True, "")
def _command_options_for_channel(user, service: str, identifier: str) -> list[dict]:
service_key = _default_service(service)
variants = _command_channel_identifier_variants(service_key, identifier)
profiles = list(
CommandProfile.objects.filter(user=user).order_by("slug", "id")
)
by_slug = {str(row.slug or "").strip(): row for row in profiles}
if "bp" not in by_slug:
by_slug["bp"] = CommandProfile(
user=user,
slug="bp",
name="Business Plan",
trigger_token="#bp#",
enabled=True,
)
slugs = sorted(by_slug.keys())
options = []
for slug in slugs:
profile = by_slug[slug]
enabled_here = False
if variants:
enabled_here = CommandChannelBinding.objects.filter(
profile_id=profile.id if profile.id else None,
direction="ingress",
service=service_key,
channel_identifier__in=list(variants),
enabled=True,
).exists()
options.append(
{
"slug": slug,
"name": str(profile.name or slug).strip() or slug,
"trigger_token": str(profile.trigger_token or "").strip(),
"enabled_here": bool(enabled_here),
"profile_enabled": bool(profile.enabled),
}
)
return options
def _compose_urls(service, identifier, person_id): def _compose_urls(service, identifier, person_id):
query = {"service": service, "identifier": identifier} query = {"service": service, "identifier": identifier}
if person_id: if person_id:
@@ -2092,6 +2331,11 @@ def _panel_context(
user_id=request.user.id, user_id=request.user.id,
person_id=base["person"].id if base["person"] else None, person_id=base["person"].id if base["person"] else None,
) )
command_options = _command_options_for_channel(
request.user,
base["service"],
base["identifier"],
)
recent_contacts = _recent_manual_contacts( recent_contacts = _recent_manual_contacts(
request.user, request.user,
current_service=base["service"], current_service=base["service"],
@@ -2126,6 +2370,7 @@ def _panel_context(
"compose_engage_send_url": reverse("compose_engage_send"), "compose_engage_send_url": reverse("compose_engage_send"),
"compose_quick_insights_url": reverse("compose_quick_insights"), "compose_quick_insights_url": reverse("compose_quick_insights"),
"compose_history_sync_url": reverse("compose_history_sync"), "compose_history_sync_url": reverse("compose_history_sync"),
"compose_toggle_command_url": reverse("compose_toggle_command"),
"compose_ws_url": ws_url, "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}"
@@ -2143,6 +2388,7 @@ def _panel_context(
"manual_icon_class": "fa-solid fa-paper-plane", "manual_icon_class": "fa-solid fa-paper-plane",
"panel_id": f"compose-panel-{unique}", "panel_id": f"compose-panel-{unique}",
"typing_state_json": json.dumps(typing_state), "typing_state_json": json.dumps(typing_state),
"command_options": command_options,
"platform_options": platform_options, "platform_options": platform_options,
"recent_contacts": recent_contacts, "recent_contacts": recent_contacts,
"is_group": base.get("is_group", False), "is_group": base.get("is_group", False),
@@ -2577,6 +2823,7 @@ class ComposeThread(LoginRequiredMixin, View):
"session", "session",
"session__identifier", "session__identifier",
"session__identifier__person", "session__identifier__person",
"reply_to",
).order_by("-ts")[:limit] ).order_by("-ts")[:limit]
) )
rows_desc.reverse() rows_desc.reverse()
@@ -2979,6 +3226,89 @@ class ComposeCommandResult(LoginRequiredMixin, View):
return JsonResponse({"pending": False, "result": result}) return JsonResponse({"pending": False, "result": result})
class ComposeToggleCommand(LoginRequiredMixin, View):
def post(self, request):
service, identifier, _ = _request_scope(request, "POST")
channel_identifier = _canonical_command_channel_identifier(
service, str(identifier or "")
)
if not channel_identifier:
return JsonResponse({"ok": False, "error": "missing_identifier"}, status=400)
if service not in {"web", "xmpp", "signal", "whatsapp"}:
return JsonResponse(
{"ok": False, "error": f"unsupported_service:{service}"},
status=400,
)
slug = str(request.POST.get("slug") or "bp").strip().lower() or "bp"
enabled = str(request.POST.get("enabled") or "1").strip().lower() in {
"1",
"true",
"yes",
"on",
}
ok, error = _toggle_command_for_channel(
user=request.user,
service=service,
identifier=channel_identifier,
slug=slug,
enabled=enabled,
)
if not ok:
return JsonResponse(
{
"ok": False,
"error": error or "command_toggle_failed",
},
status=400,
)
command_options = _command_options_for_channel(
request.user, service, channel_identifier
)
for row in command_options:
if row.get("slug") == slug:
row["enabled_here"] = bool(enabled)
message = (
f"{slug} enabled for this chat."
if enabled
else f"{slug} disabled for this chat."
)
return JsonResponse(
{
"ok": True,
"message": message,
"slug": slug,
"enabled": bool(enabled),
"command_options": command_options,
"settings_url": reverse("command_routing"),
}
)
class ComposeBindBP(ComposeToggleCommand):
def post(self, request):
service, identifier, _ = _request_scope(request, "POST")
ok, error = _toggle_command_for_channel(
user=request.user,
service=service,
identifier=str(identifier or ""),
slug="bp",
enabled=True,
)
if not ok:
return JsonResponse(
{"ok": False, "error": error or "command_toggle_failed"},
status=400,
)
return JsonResponse(
{
"ok": True,
"message": "bp enabled for this chat.",
"slug": "bp",
"enabled": True,
"settings_url": reverse("command_routing"),
}
)
class ComposeMediaBlob(LoginRequiredMixin, View): class ComposeMediaBlob(LoginRequiredMixin, View):
""" """
Serve cached media blobs for authenticated compose image previews. Serve cached media blobs for authenticated compose image previews.
@@ -3477,6 +3807,7 @@ class ComposeSend(LoginRequiredMixin, View):
) )
text = str(request.POST.get("text") or "").strip() text = str(request.POST.get("text") or "").strip()
reply_to_message_id = str(request.POST.get("reply_to_message_id") or "").strip()
if not text: if not text:
return self._response( return self._response(
request, request,
@@ -3503,7 +3834,26 @@ class ComposeSend(LoginRequiredMixin, View):
) )
ts = None ts = None
command_id = None command_id = None
created_message = None
session = None
reply_to = None
if base["person_identifier"] is not None:
session, _ = ChatSession.objects.get_or_create(
user=request.user,
identifier=base["person_identifier"],
)
if reply_to_message_id:
reply_to = Message.objects.filter(
user=request.user,
session=session,
id=reply_to_message_id,
).first()
if runtime_client is None: if runtime_client is None:
outbound_reply_metadata = {}
if base["service"] == "whatsapp":
outbound_reply_metadata = _build_whatsapp_reply_metadata(
reply_to, str(base["identifier"] or "")
)
if base["service"] == "whatsapp": if base["service"] == "whatsapp":
runtime_state = transport.get_runtime_state("whatsapp") runtime_state = transport.get_runtime_state("whatsapp")
last_seen = int(runtime_state.get("runtime_seen_at") or 0) last_seen = int(runtime_state.get("runtime_seen_at") or 0)
@@ -3530,24 +3880,62 @@ class ComposeSend(LoginRequiredMixin, View):
level="warning", level="warning",
panel_id=panel_id, panel_id=panel_id,
) )
# Persist local message first so runtime can attach upstream bridge refs.
ts = int(time.time() * 1000)
if session is not None:
created_message = Message.objects.create(
user=request.user,
session=session,
sender_uuid="",
text=text,
ts=int(ts),
delivered_ts=None,
custom_author="USER",
source_service="web",
source_message_id=str(ts),
source_chat_id=str(base["identifier"] or ""),
reply_to=reply_to,
reply_source_service="web" if reply_to is not None else None,
reply_source_message_id=(
str(reply_to.id) if reply_to is not None else None
),
message_meta={},
)
command_id = transport.enqueue_runtime_command( command_id = transport.enqueue_runtime_command(
base["service"], base["service"],
"send_message_raw", "send_message_raw",
{"recipient": base["identifier"], "text": text, "attachments": []}, {
"recipient": base["identifier"],
"text": text,
"attachments": [],
"metadata": (
{
"legacy_message_id": str(created_message.id),
"local_ts": int(ts),
**outbound_reply_metadata,
}
if created_message is not None
else outbound_reply_metadata
),
},
) )
logger.debug(f"{log_prefix} command_id={command_id} enqueued") logger.debug(f"{log_prefix} command_id={command_id} enqueued")
# attach command id to request so _response can include it in HX-Trigger # attach command id to request so _response can include it in HX-Trigger
request._compose_command_id = command_id request._compose_command_id = command_id
# Do NOT wait here — return immediately so the UI doesn't block. # Do NOT wait here — return immediately so the UI doesn't block.
# Record a pending message locally so the thread shows the outgoing message.
ts = int(time.time() * 1000)
else: else:
# In-process runtime can perform the send synchronously and return a timestamp. # In-process runtime can perform the send synchronously and return a timestamp.
outbound_reply_metadata = {}
if base["service"] == "whatsapp":
outbound_reply_metadata = _build_whatsapp_reply_metadata(
reply_to, str(base["identifier"] or "")
)
ts = async_to_sync(transport.send_message_raw)( ts = async_to_sync(transport.send_message_raw)(
base["service"], base["service"],
base["identifier"], base["identifier"],
text=text, text=text,
attachments=[], attachments=[],
metadata=outbound_reply_metadata,
) )
# For queued sends we set `ts` to a local timestamp; for in-process sends ts may be False. # For queued sends we set `ts` to a local timestamp; for in-process sends ts may be False.
if not ts: if not ts:
@@ -3559,17 +3947,13 @@ class ComposeSend(LoginRequiredMixin, View):
panel_id=panel_id, panel_id=panel_id,
) )
if base["person_identifier"] is not None: if base["person_identifier"] is not None and created_message is None:
session, _ = ChatSession.objects.get_or_create(
user=request.user,
identifier=base["person_identifier"],
)
# For in-process sends (Signal, etc), ts is a timestamp or True. # For in-process sends (Signal, etc), ts is a timestamp or True.
# For queued sends (WhatsApp/UR), ts is a local timestamp. # For queued sends (WhatsApp/UR), ts is a local timestamp.
# Set delivered_ts only if we got a real timestamp OR if it's an in-process sync send. # Set delivered_ts only if we got a real timestamp OR if it's an in-process sync send.
msg_ts = int(ts) if str(ts).isdigit() else int(time.time() * 1000) msg_ts = int(ts) if str(ts).isdigit() else int(time.time() * 1000)
delivered_ts = msg_ts if runtime_client is not None else None delivered_ts = msg_ts if runtime_client is not None else None
Message.objects.create( created_message = Message.objects.create(
user=request.user, user=request.user,
session=session, session=session,
sender_uuid="", sender_uuid="",
@@ -3577,7 +3961,26 @@ class ComposeSend(LoginRequiredMixin, View):
ts=msg_ts, ts=msg_ts,
delivered_ts=delivered_ts, delivered_ts=delivered_ts,
custom_author="USER", custom_author="USER",
source_service="web",
source_message_id=str(msg_ts),
source_chat_id=str(base["identifier"] or ""),
reply_to=reply_to,
reply_source_service="web" if reply_to is not None else None,
reply_source_message_id=str(reply_to.id) if reply_to is not None else None,
message_meta={},
) )
if created_message is not None:
async_to_sync(process_inbound_message)(
CommandContext(
service="web",
channel_identifier=str(base["identifier"] or ""),
message_id=str(created_message.id),
user_id=int(request.user.id),
message_text=text,
payload={},
)
)
async_to_sync(process_inbound_translation)(created_message)
# Notify XMPP clients from runtime so cross-platform sends appear there too. # Notify XMPP clients from runtime so cross-platform sends appear there too.
if base["service"] in {"signal", "whatsapp"}: if base["service"] in {"signal", "whatsapp"}:
try: try: