Implement business plans
This commit is contained in:
@@ -11,7 +11,7 @@ from django.urls import reverse
|
||||
from signalbot import Command, Context, SignalBot
|
||||
|
||||
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.util import logs
|
||||
|
||||
@@ -358,6 +358,13 @@ class HandleMessage(Command):
|
||||
ts = c.message.timestamp
|
||||
source_value = c.message.source
|
||||
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")
|
||||
|
||||
bot_uuid = str(getattr(c.bot, "bot_uuid", "") or "").strip()
|
||||
@@ -639,16 +646,36 @@ class HandleMessage(Command):
|
||||
identifier.user, identifier
|
||||
)
|
||||
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]
|
||||
message_key = (chat_session.id, ts, sender_key)
|
||||
message_text = identifier_text_overrides.get(session_key, relay_text)
|
||||
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,
|
||||
sender=sender_key,
|
||||
text=message_text,
|
||||
ts=ts,
|
||||
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)
|
||||
# Notify unified router to ensure service context is preserved
|
||||
@@ -658,6 +685,7 @@ class HandleMessage(Command):
|
||||
text=message_text,
|
||||
ts=ts,
|
||||
payload=msg,
|
||||
local_message=local_message,
|
||||
)
|
||||
|
||||
# TODO: Permission checks
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
@@ -14,9 +15,14 @@ from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
from google.protobuf.json_format import MessageToDict
|
||||
except Exception: # pragma: no cover
|
||||
MessageToDict = None
|
||||
|
||||
|
||||
class WhatsAppClient(ClientBase):
|
||||
"""
|
||||
@@ -45,6 +51,9 @@ class WhatsAppClient(ClientBase):
|
||||
self._qr_handler_supported = False
|
||||
self._event_hook_callable = False
|
||||
self._last_send_error = ""
|
||||
self.reply_debug_chat = str(
|
||||
getattr(settings, "WHATSAPP_REPLY_DEBUG_CHAT", "120363402761690215")
|
||||
).strip()
|
||||
|
||||
self.enabled = bool(
|
||||
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 []
|
||||
|
||||
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):
|
||||
if raw_value is None:
|
||||
return int(time.time() * 1000)
|
||||
@@ -2361,19 +2436,22 @@ class WhatsAppClient(ClientBase):
|
||||
]
|
||||
|
||||
async def _handle_message_event(self, event):
|
||||
msg_obj = self._pluck(event, "message") or self._pluck(event, "Message")
|
||||
text = self._message_text(msg_obj, event)
|
||||
event_obj = self._proto_to_dict(event) or 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:
|
||||
self.log.debug(
|
||||
"whatsapp empty-text event shape: msg_keys=%s event_keys=%s type=%s",
|
||||
self._shape_keys(msg_obj),
|
||||
self._shape_keys(event),
|
||||
self._shape_keys(event_obj),
|
||||
str(type(event).__name__),
|
||||
)
|
||||
source = (
|
||||
self._pluck(event, "Info", "MessageSource")
|
||||
or self._pluck(event, "info", "message_source")
|
||||
or self._pluck(event, "info", "messageSource")
|
||||
self._pluck(event_obj, "Info", "MessageSource")
|
||||
or self._pluck(event_obj, "info", "message_source")
|
||||
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(
|
||||
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")
|
||||
)
|
||||
raw_ts = (
|
||||
self._pluck(event, "Info", "Timestamp")
|
||||
or self._pluck(event, "info", "timestamp")
|
||||
or self._pluck(event, "info", "message_timestamp")
|
||||
or self._pluck(event, "Timestamp")
|
||||
or self._pluck(event, "timestamp")
|
||||
self._pluck(event_obj, "Info", "Timestamp")
|
||||
or self._pluck(event_obj, "info", "timestamp")
|
||||
or self._pluck(event_obj, "info", "message_timestamp")
|
||||
or self._pluck(event_obj, "Timestamp")
|
||||
or self._pluck(event_obj, "timestamp")
|
||||
)
|
||||
msg_id = str(
|
||||
self._pluck(event, "Info", "ID")
|
||||
or self._pluck(event, "info", "id")
|
||||
or self._pluck(event, "ID")
|
||||
or self._pluck(event, "id")
|
||||
self._pluck(event_obj, "Info", "ID")
|
||||
or self._pluck(event_obj, "info", "id")
|
||||
or self._pluck(event_obj, "ID")
|
||||
or self._pluck(event_obj, "id")
|
||||
or ""
|
||||
).strip()
|
||||
ts = self._normalize_timestamp(raw_ts)
|
||||
@@ -2529,12 +2607,196 @@ class WhatsAppClient(ClientBase):
|
||||
)()
|
||||
if duplicate_exists:
|
||||
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,
|
||||
sender=str(sender or chat or ""),
|
||||
text=display_text,
|
||||
ts=ts,
|
||||
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(
|
||||
self.service,
|
||||
@@ -2542,6 +2804,7 @@ class WhatsAppClient(ClientBase):
|
||||
text=display_text,
|
||||
ts=ts,
|
||||
payload=payload,
|
||||
local_message=local_message,
|
||||
)
|
||||
|
||||
async def _handle_receipt_event(self, event):
|
||||
@@ -2679,6 +2942,11 @@ class WhatsAppClient(ClientBase):
|
||||
return ""
|
||||
if "@" in 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)
|
||||
if digits:
|
||||
# Prefer direct JID formatting for phone numbers; Neonize build_jid
|
||||
@@ -2691,6 +2959,66 @@ class WhatsAppClient(ClientBase):
|
||||
pass
|
||||
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):
|
||||
key = str(blob_key or "").strip()
|
||||
if not key:
|
||||
@@ -2806,8 +3134,31 @@ class WhatsAppClient(ClientBase):
|
||||
metadata = dict(metadata or {})
|
||||
xmpp_source_id = str(metadata.get("xmpp_source_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
|
||||
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))
|
||||
if candidates:
|
||||
person_identifier = await sync_to_async(
|
||||
@@ -2828,8 +3179,25 @@ class WhatsAppClient(ClientBase):
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
def _record_bridge(response, ts_value, body_hint=""):
|
||||
if not xmpp_source_id or person_identifier is None:
|
||||
async def _record_bridge(response, ts_value, body_hint=""):
|
||||
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
|
||||
transport.record_bridge_mapping(
|
||||
user_id=person_identifier.user_id,
|
||||
@@ -2837,7 +3205,7 @@ class WhatsAppClient(ClientBase):
|
||||
service="whatsapp",
|
||||
xmpp_message_id=xmpp_source_id,
|
||||
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),
|
||||
text_preview=str(body_hint or metadata.get("xmpp_body") or ""),
|
||||
local_message_id=legacy_message_id,
|
||||
@@ -2899,7 +3267,7 @@ class WhatsAppClient(ClientBase):
|
||||
sent_ts,
|
||||
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
|
||||
if getattr(settings, "WHATSAPP_DEBUG", False):
|
||||
self.log.debug(
|
||||
@@ -2916,6 +3284,35 @@ class WhatsAppClient(ClientBase):
|
||||
if text:
|
||||
response = 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)
|
||||
cancel_key = None
|
||||
try:
|
||||
@@ -2945,7 +3342,7 @@ class WhatsAppClient(ClientBase):
|
||||
response = await self._call_client_method(
|
||||
getattr(self._client, "send_message", None),
|
||||
send_target,
|
||||
text,
|
||||
quoted_text_message,
|
||||
timeout=9.0,
|
||||
)
|
||||
sent_any = True
|
||||
@@ -3030,7 +3427,7 @@ class WhatsAppClient(ClientBase):
|
||||
sent_ts,
|
||||
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:
|
||||
self._last_send_error = "no_payload_sent"
|
||||
|
||||
@@ -16,7 +16,7 @@ from slixmpp.xmlstream import register_stanza_plugin
|
||||
from slixmpp.xmlstream.stanzabase import ET
|
||||
|
||||
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 (
|
||||
ChatSession,
|
||||
Manipulation,
|
||||
@@ -1236,14 +1236,46 @@ class XMPPComponent(ComponentXMPP):
|
||||
user=identifier.user,
|
||||
)
|
||||
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(
|
||||
session=session,
|
||||
sender="XMPP",
|
||||
text=body,
|
||||
ts=int(now().timestamp() * 1000),
|
||||
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")
|
||||
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(
|
||||
group__people=identifier.person,
|
||||
|
||||
0
core/commands/__init__.py
Normal file
0
core/commands/__init__.py
Normal file
29
core/commands/base.py
Normal file
29
core/commands/base.py
Normal 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
125
core/commands/engine.py
Normal 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
|
||||
0
core/commands/handlers/__init__.py
Normal file
0
core/commands/handlers/__init__.py
Normal file
358
core/commands/handlers/bp.py
Normal file
358
core/commands/handlers/bp.py
Normal 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
16
core/commands/registry.py
Normal 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())
|
||||
@@ -144,7 +144,20 @@ async def get_chat_session(user, identifier):
|
||||
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)
|
||||
msg = await sync_to_async(Message.objects.create)(
|
||||
user=session.user,
|
||||
@@ -154,12 +167,32 @@ async def store_message(session, sender, text, ts, outgoing=False):
|
||||
ts=ts,
|
||||
delivered_ts=ts,
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
cast = {
|
||||
"user": session.user,
|
||||
@@ -168,6 +201,13 @@ async def store_own_message(session, text, ts, manip=None, queue=False):
|
||||
"text": text,
|
||||
"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:
|
||||
msg_object = QueuedMessage
|
||||
|
||||
391
core/messaging/reply_sync.py
Normal file
391
core/messaging/reply_sync.py
Normal 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")))
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
317
core/models.py
317
core/models.py
@@ -18,6 +18,7 @@ SERVICE_CHOICES = (
|
||||
("xmpp", "XMPP"),
|
||||
("instagram", "Instagram"),
|
||||
)
|
||||
CHANNEL_SERVICE_CHOICES = SERVICE_CHOICES + (("web", "Web"),)
|
||||
MBTI_CHOICES = (
|
||||
("INTJ", "INTJ - Architect"),
|
||||
("INTP", "INTP - Logician"),
|
||||
@@ -297,9 +298,61 @@ class Message(models.Model):
|
||||
blank=True,
|
||||
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:
|
||||
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):
|
||||
@@ -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 Meta:
|
||||
# permissions = (
|
||||
|
||||
@@ -8,9 +8,12 @@ from core.clients.instagram import InstagramClient
|
||||
from core.clients.signal import SignalClient
|
||||
from core.clients.whatsapp import WhatsAppClient
|
||||
from core.clients.xmpp import XMPPClient
|
||||
from core.commands.base import CommandContext
|
||||
from core.commands.engine import process_inbound_message
|
||||
from core.messaging import history
|
||||
from core.models import PersonIdentifier
|
||||
from core.realtime.typing_state import set_person_typing_state
|
||||
from core.translation.engine import process_inbound_translation
|
||||
from core.util import logs
|
||||
|
||||
|
||||
@@ -91,6 +94,34 @@ class UnifiedRouter(object):
|
||||
|
||||
async def message_received(self, 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):
|
||||
if isinstance(identifier, PersonIdentifier):
|
||||
|
||||
@@ -392,6 +392,9 @@
|
||||
<a class="navbar-item" href="{% url 'ais' type='page' %}">
|
||||
AI
|
||||
</a>
|
||||
<a class="navbar-item" href="{% url 'command_routing' %}">
|
||||
Command Routing
|
||||
</a>
|
||||
{% if user.is_superuser %}
|
||||
<a class="navbar-item" href="{% url 'system_settings' %}">
|
||||
System
|
||||
|
||||
57
core/templates/pages/business-plan-editor.html
Normal file
57
core/templates/pages/business-plan-editor.html
Normal 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 %}
|
||||
267
core/templates/pages/command-routing.html
Normal file
267
core/templates/pages/command-routing.html
Normal 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 %}
|
||||
@@ -72,6 +72,31 @@
|
||||
<span class="icon is-small"><i class="fa-solid fa-rotate-right"></i></span>
|
||||
<span>Force Sync</span>
|
||||
</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">
|
||||
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
|
||||
<span>Drafts</span>
|
||||
@@ -241,10 +266,11 @@
|
||||
data-summary-url="{{ compose_summary_url }}"
|
||||
data-quick-insights-url="{{ compose_quick_insights_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-send-url="{{ compose_engage_send_url }}">
|
||||
{% 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 %}
|
||||
{% with gap=msg.gap_fragments.0 %}
|
||||
<p
|
||||
@@ -256,6 +282,11 @@
|
||||
{% endwith %}
|
||||
{% 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">
|
||||
<span class="compose-source-badge source-{{ msg.source_service|default:'web'|lower }}">{{ msg.source_label }}</span>
|
||||
</div>
|
||||
@@ -336,6 +367,10 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
</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>
|
||||
</div>
|
||||
{% empty %}
|
||||
@@ -365,6 +400,7 @@
|
||||
<input type="hidden" name="render_mode" value="{{ render_mode }}">
|
||||
<input type="hidden" name="limit" value="{{ limit }}">
|
||||
<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_confirm" value="0">
|
||||
<div class="compose-send-safety">
|
||||
@@ -372,6 +408,11 @@
|
||||
<input type="checkbox" class="manual-confirm"> Confirm Send
|
||||
</label>
|
||||
</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">
|
||||
<textarea
|
||||
id="{{ panel_id }}-textarea"
|
||||
@@ -414,6 +455,13 @@
|
||||
gap: 0.3rem;
|
||||
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 {
|
||||
align-items: flex-start;
|
||||
}
|
||||
@@ -478,6 +526,56 @@
|
||||
padding: 0.52rem 0.62rem;
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
@@ -667,6 +765,47 @@
|
||||
#{{ panel_id }} .compose-platform-select {
|
||||
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 {
|
||||
align-self: center;
|
||||
width: min(92%, 34rem);
|
||||
@@ -800,6 +939,38 @@
|
||||
margin-bottom: 0.45rem;
|
||||
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 {
|
||||
margin-top: 0.55rem;
|
||||
min-height: 1.1rem;
|
||||
@@ -1252,6 +1423,10 @@
|
||||
50% { transform: translateX(2px); }
|
||||
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) {
|
||||
#{{ panel_id }} .compose-thread {
|
||||
max-height: 52vh;
|
||||
@@ -1294,6 +1469,10 @@
|
||||
const hiddenService = document.getElementById(panelId + "-input-service");
|
||||
const hiddenIdentifier = document.getElementById(panelId + "-input-identifier");
|
||||
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 }}";
|
||||
if (!thread || !form || !textarea) {
|
||||
return;
|
||||
@@ -1348,6 +1527,7 @@
|
||||
lightboxIndex: -1,
|
||||
seenMessageIds: new Set(),
|
||||
replyTimingTimer: null,
|
||||
replyTargetId: "",
|
||||
};
|
||||
window.giaComposePanels[panelId] = panelState;
|
||||
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) {
|
||||
if (!thread) {
|
||||
@@ -2060,6 +2314,12 @@
|
||||
row.className = "compose-row " + (outgoing ? "is-out" : "is-in");
|
||||
row.dataset.ts = String(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) {
|
||||
row.dataset.messageId = messageId;
|
||||
panelState.seenMessageIds.add(messageId);
|
||||
@@ -2069,6 +2329,19 @@
|
||||
const bubble = document.createElement("article");
|
||||
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
|
||||
if (msg.source_label) {
|
||||
const badgeWrap = document.createElement("div");
|
||||
@@ -2178,6 +2451,14 @@
|
||||
meta.appendChild(tickWrap);
|
||||
}
|
||||
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 (msg.receipt_payload || msg.read_source_service || msg.read_by_identifier) {
|
||||
@@ -2202,6 +2483,7 @@
|
||||
row.appendChild(bubble);
|
||||
insertRowByTs(row);
|
||||
wireImageFallbacks(row);
|
||||
bindReplyReferences(row);
|
||||
updateGlanceFromMessage(msg);
|
||||
};
|
||||
|
||||
@@ -2261,6 +2543,19 @@
|
||||
|
||||
// Delegate click on tick triggers inside thread
|
||||
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');
|
||||
if (!btn) return;
|
||||
if (activeReceiptBtn === btn && !receiptPopover.classList.contains('is-hidden')) {
|
||||
@@ -2456,6 +2751,7 @@
|
||||
}
|
||||
applyMinuteGrouping();
|
||||
bindHistorySyncButtons(panel);
|
||||
bindCommandMenu(panel);
|
||||
|
||||
const setStatus = function (message, level) {
|
||||
if (!statusBox) {
|
||||
@@ -2551,6 +2847,135 @@
|
||||
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 response = await fetch(url, {
|
||||
method: "POST",
|
||||
@@ -2619,6 +3044,7 @@
|
||||
if (metaLine) {
|
||||
metaLine.textContent = titleCase(service) + " · " + identifier;
|
||||
}
|
||||
clearReplyTarget();
|
||||
if (panelState.socket) {
|
||||
try {
|
||||
panelState.socket.close();
|
||||
@@ -3468,6 +3894,7 @@
|
||||
if (result.ok) {
|
||||
setStatus('', 'success');
|
||||
textarea.value = '';
|
||||
clearReplyTarget();
|
||||
autosize();
|
||||
flashCompose('is-send-success');
|
||||
poll(true);
|
||||
@@ -3552,6 +3979,7 @@
|
||||
flashCompose("is-send-success");
|
||||
setStatus("", "success");
|
||||
textarea.value = "";
|
||||
clearReplyTarget();
|
||||
autosize();
|
||||
poll(true);
|
||||
} else {
|
||||
|
||||
138
core/tests/test_bp_fallback.py
Normal file
138
core/tests/test_bp_fallback.py
Normal 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)
|
||||
241
core/tests/test_phase1_command_reply.py
Normal file
241
core/tests/test_phase1_command_reply.py
Normal 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)
|
||||
38
core/tests/test_whatsapp_send_routing.py
Normal file
38
core/tests/test_whatsapp_send_routing.py
Normal 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)
|
||||
0
core/translation/__init__.py
Normal file
0
core/translation/__init__.py
Normal file
145
core/translation/engine.py
Normal file
145
core/translation/engine.py
Normal 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
249
core/views/automation.py
Normal 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.",
|
||||
}
|
||||
)
|
||||
@@ -26,6 +26,8 @@ from django.utils import timezone as dj_timezone
|
||||
from django.views import View
|
||||
|
||||
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 media_bridge
|
||||
from core.messaging.utils import messages_to_string
|
||||
@@ -33,6 +35,9 @@ from core.models import (
|
||||
AI,
|
||||
Chat,
|
||||
ChatSession,
|
||||
CommandAction,
|
||||
CommandChannelBinding,
|
||||
CommandProfile,
|
||||
Message,
|
||||
MessageEvent,
|
||||
PatternMitigationPlan,
|
||||
@@ -42,6 +47,7 @@ from core.models import (
|
||||
WorkspaceConversation,
|
||||
)
|
||||
from core.realtime.typing_state import get_person_typing_state
|
||||
from core.translation.engine import process_inbound_translation
|
||||
from core.views.workspace import (
|
||||
INSIGHT_METRICS,
|
||||
_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 {
|
||||
"id": str(msg.id),
|
||||
"ts": int(msg.ts or 0),
|
||||
@@ -491,6 +509,15 @@ def _serialize_message(msg: Message) -> dict:
|
||||
"read_source_service": read_source_service,
|
||||
"read_by_identifier": read_by_identifier,
|
||||
"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
|
||||
identifier = person_identifier.identifier
|
||||
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:
|
||||
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):
|
||||
query = {"service": service, "identifier": identifier}
|
||||
if person_id:
|
||||
@@ -2092,6 +2331,11 @@ def _panel_context(
|
||||
user_id=request.user.id,
|
||||
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(
|
||||
request.user,
|
||||
current_service=base["service"],
|
||||
@@ -2126,6 +2370,7 @@ def _panel_context(
|
||||
"compose_engage_send_url": reverse("compose_engage_send"),
|
||||
"compose_quick_insights_url": reverse("compose_quick_insights"),
|
||||
"compose_history_sync_url": reverse("compose_history_sync"),
|
||||
"compose_toggle_command_url": reverse("compose_toggle_command"),
|
||||
"compose_ws_url": ws_url,
|
||||
"ai_workspace_url": (
|
||||
f"{reverse('ai_workspace')}?person={base['person'].id}"
|
||||
@@ -2143,6 +2388,7 @@ def _panel_context(
|
||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||
"panel_id": f"compose-panel-{unique}",
|
||||
"typing_state_json": json.dumps(typing_state),
|
||||
"command_options": command_options,
|
||||
"platform_options": platform_options,
|
||||
"recent_contacts": recent_contacts,
|
||||
"is_group": base.get("is_group", False),
|
||||
@@ -2577,6 +2823,7 @@ class ComposeThread(LoginRequiredMixin, View):
|
||||
"session",
|
||||
"session__identifier",
|
||||
"session__identifier__person",
|
||||
"reply_to",
|
||||
).order_by("-ts")[:limit]
|
||||
)
|
||||
rows_desc.reverse()
|
||||
@@ -2979,6 +3226,89 @@ class ComposeCommandResult(LoginRequiredMixin, View):
|
||||
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):
|
||||
"""
|
||||
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()
|
||||
reply_to_message_id = str(request.POST.get("reply_to_message_id") or "").strip()
|
||||
if not text:
|
||||
return self._response(
|
||||
request,
|
||||
@@ -3503,7 +3834,26 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
)
|
||||
ts = 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:
|
||||
outbound_reply_metadata = {}
|
||||
if base["service"] == "whatsapp":
|
||||
outbound_reply_metadata = _build_whatsapp_reply_metadata(
|
||||
reply_to, str(base["identifier"] or "")
|
||||
)
|
||||
if base["service"] == "whatsapp":
|
||||
runtime_state = transport.get_runtime_state("whatsapp")
|
||||
last_seen = int(runtime_state.get("runtime_seen_at") or 0)
|
||||
@@ -3530,24 +3880,62 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
level="warning",
|
||||
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(
|
||||
base["service"],
|
||||
"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")
|
||||
# attach command id to request so _response can include it in HX-Trigger
|
||||
request._compose_command_id = command_id
|
||||
# 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:
|
||||
# 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)(
|
||||
base["service"],
|
||||
base["identifier"],
|
||||
text=text,
|
||||
attachments=[],
|
||||
metadata=outbound_reply_metadata,
|
||||
)
|
||||
# For queued sends we set `ts` to a local timestamp; for in-process sends ts may be False.
|
||||
if not ts:
|
||||
@@ -3559,17 +3947,13 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
panel_id=panel_id,
|
||||
)
|
||||
|
||||
if base["person_identifier"] is not None:
|
||||
session, _ = ChatSession.objects.get_or_create(
|
||||
user=request.user,
|
||||
identifier=base["person_identifier"],
|
||||
)
|
||||
if base["person_identifier"] is not None and created_message is None:
|
||||
# For in-process sends (Signal, etc), ts is a timestamp or True.
|
||||
# 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.
|
||||
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
|
||||
Message.objects.create(
|
||||
created_message = Message.objects.create(
|
||||
user=request.user,
|
||||
session=session,
|
||||
sender_uuid="",
|
||||
@@ -3577,7 +3961,26 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
ts=msg_ts,
|
||||
delivered_ts=delivered_ts,
|
||||
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.
|
||||
if base["service"] in {"signal", "whatsapp"}:
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user