Implement business plans
This commit is contained in:
26
app/urls.py
26
app/urls.py
@@ -22,6 +22,7 @@ from two_factor.urls import urlpatterns as tf_urls
|
|||||||
|
|
||||||
from core.views import (
|
from core.views import (
|
||||||
ais,
|
ais,
|
||||||
|
automation,
|
||||||
base,
|
base,
|
||||||
compose,
|
compose,
|
||||||
groups,
|
groups,
|
||||||
@@ -60,6 +61,21 @@ urlpatterns = [
|
|||||||
system.SystemSettings.as_view(),
|
system.SystemSettings.as_view(),
|
||||||
name="system_settings",
|
name="system_settings",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"settings/command-routing/",
|
||||||
|
automation.CommandRoutingSettings.as_view(),
|
||||||
|
name="command_routing",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"settings/business-plan/<str:doc_id>/",
|
||||||
|
automation.BusinessPlanEditor.as_view(),
|
||||||
|
name="business_plan_editor",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"settings/translation/preview/",
|
||||||
|
automation.TranslationPreview.as_view(),
|
||||||
|
name="translation_preview",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"services/signal/",
|
"services/signal/",
|
||||||
signal.Signal.as_view(),
|
signal.Signal.as_view(),
|
||||||
@@ -205,6 +221,16 @@ urlpatterns = [
|
|||||||
compose.ComposeHistorySync.as_view(),
|
compose.ComposeHistorySync.as_view(),
|
||||||
name="compose_history_sync",
|
name="compose_history_sync",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"compose/commands/bp/bind/",
|
||||||
|
compose.ComposeBindBP.as_view(),
|
||||||
|
name="compose_bind_bp",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"compose/commands/toggle/",
|
||||||
|
compose.ComposeToggleCommand.as_view(),
|
||||||
|
name="compose_toggle_command",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"compose/media/blob/",
|
"compose/media/blob/",
|
||||||
compose.ComposeMediaBlob.as_view(),
|
compose.ComposeMediaBlob.as_view(),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from django.urls import reverse
|
|||||||
from signalbot import Command, Context, SignalBot
|
from signalbot import Command, Context, SignalBot
|
||||||
|
|
||||||
from core.clients import ClientBase, signalapi
|
from core.clients import ClientBase, signalapi
|
||||||
from core.messaging import ai, history, media_bridge, natural, replies, utils
|
from core.messaging import ai, history, media_bridge, natural, replies, reply_sync, utils
|
||||||
from core.models import Chat, Manipulation, PersonIdentifier, PlatformChatLink, QueuedMessage
|
from core.models import Chat, Manipulation, PersonIdentifier, PlatformChatLink, QueuedMessage
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
|
||||||
@@ -358,6 +358,13 @@ class HandleMessage(Command):
|
|||||||
ts = c.message.timestamp
|
ts = c.message.timestamp
|
||||||
source_value = c.message.source
|
source_value = c.message.source
|
||||||
envelope = raw.get("envelope", {})
|
envelope = raw.get("envelope", {})
|
||||||
|
signal_source_message_id = str(
|
||||||
|
envelope.get("serverGuid")
|
||||||
|
or envelope.get("guid")
|
||||||
|
or envelope.get("timestamp")
|
||||||
|
or c.message.timestamp
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
destination_number = sent_message.get("destination")
|
destination_number = sent_message.get("destination")
|
||||||
|
|
||||||
bot_uuid = str(getattr(c.bot, "bot_uuid", "") or "").strip()
|
bot_uuid = str(getattr(c.bot, "bot_uuid", "") or "").strip()
|
||||||
@@ -639,16 +646,36 @@ class HandleMessage(Command):
|
|||||||
identifier.user, identifier
|
identifier.user, identifier
|
||||||
)
|
)
|
||||||
session_cache[session_key] = chat_session
|
session_cache[session_key] = chat_session
|
||||||
|
reply_ref = reply_sync.extract_reply_ref(self.service, raw)
|
||||||
|
reply_target = await reply_sync.resolve_reply_target(
|
||||||
|
identifier.user,
|
||||||
|
chat_session,
|
||||||
|
reply_ref,
|
||||||
|
)
|
||||||
sender_key = source_uuid or source_number or identifier_candidates[0]
|
sender_key = source_uuid or source_number or identifier_candidates[0]
|
||||||
message_key = (chat_session.id, ts, sender_key)
|
message_key = (chat_session.id, ts, sender_key)
|
||||||
message_text = identifier_text_overrides.get(session_key, relay_text)
|
message_text = identifier_text_overrides.get(session_key, relay_text)
|
||||||
if message_key not in stored_messages:
|
if message_key not in stored_messages:
|
||||||
await history.store_message(
|
origin_tag = reply_sync.extract_origin_tag(raw)
|
||||||
|
local_message = await history.store_message(
|
||||||
session=chat_session,
|
session=chat_session,
|
||||||
sender=sender_key,
|
sender=sender_key,
|
||||||
text=message_text,
|
text=message_text,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
outgoing=is_from_bot,
|
outgoing=is_from_bot,
|
||||||
|
source_service=self.service,
|
||||||
|
source_message_id=signal_source_message_id,
|
||||||
|
source_chat_id=str(
|
||||||
|
destination_number_norm or dest_norm or sender_key or ""
|
||||||
|
),
|
||||||
|
reply_to=reply_target,
|
||||||
|
reply_source_service=str(
|
||||||
|
reply_ref.get("reply_source_service") or ""
|
||||||
|
),
|
||||||
|
reply_source_message_id=str(
|
||||||
|
reply_ref.get("reply_source_message_id") or ""
|
||||||
|
),
|
||||||
|
message_meta=reply_sync.apply_sync_origin({}, origin_tag),
|
||||||
)
|
)
|
||||||
stored_messages.add(message_key)
|
stored_messages.add(message_key)
|
||||||
# Notify unified router to ensure service context is preserved
|
# Notify unified router to ensure service context is preserved
|
||||||
@@ -658,6 +685,7 @@ class HandleMessage(Command):
|
|||||||
text=message_text,
|
text=message_text,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
payload=msg,
|
payload=msg,
|
||||||
|
local_message=local_message,
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: Permission checks
|
# TODO: Permission checks
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import inspect
|
import inspect
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
@@ -14,9 +15,14 @@ from django.conf import settings
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
from core.clients import ClientBase, transport
|
from core.clients import ClientBase, transport
|
||||||
from core.messaging import history, media_bridge
|
from core.messaging import history, media_bridge, reply_sync
|
||||||
from core.models import Message, PersonIdentifier, PlatformChatLink
|
from core.models import Message, PersonIdentifier, PlatformChatLink
|
||||||
|
|
||||||
|
try:
|
||||||
|
from google.protobuf.json_format import MessageToDict
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
MessageToDict = None
|
||||||
|
|
||||||
|
|
||||||
class WhatsAppClient(ClientBase):
|
class WhatsAppClient(ClientBase):
|
||||||
"""
|
"""
|
||||||
@@ -45,6 +51,9 @@ class WhatsAppClient(ClientBase):
|
|||||||
self._qr_handler_supported = False
|
self._qr_handler_supported = False
|
||||||
self._event_hook_callable = False
|
self._event_hook_callable = False
|
||||||
self._last_send_error = ""
|
self._last_send_error = ""
|
||||||
|
self.reply_debug_chat = str(
|
||||||
|
getattr(settings, "WHATSAPP_REPLY_DEBUG_CHAT", "120363402761690215")
|
||||||
|
).strip()
|
||||||
|
|
||||||
self.enabled = bool(
|
self.enabled = bool(
|
||||||
str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower()
|
str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower()
|
||||||
@@ -1464,6 +1473,72 @@ class WhatsAppClient(ClientBase):
|
|||||||
return sorted(str(key) for key in vars(obj).keys())
|
return sorted(str(key) for key in vars(obj).keys())
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def _proto_to_dict(self, obj):
|
||||||
|
if obj is None:
|
||||||
|
return {}
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj
|
||||||
|
# Neonize emits protobuf objects for inbound events. Convert them to a
|
||||||
|
# plain dict so nested contextInfo reply fields are addressable.
|
||||||
|
if MessageToDict is not None and hasattr(obj, "DESCRIPTOR"):
|
||||||
|
try:
|
||||||
|
return MessageToDict(
|
||||||
|
obj,
|
||||||
|
preserving_proto_field_name=True,
|
||||||
|
use_integers_for_enums=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _chat_matches_reply_debug(self, chat: str) -> bool:
|
||||||
|
target = str(self.reply_debug_chat or "").strip()
|
||||||
|
value = str(chat or "").strip()
|
||||||
|
if not target or not value:
|
||||||
|
return False
|
||||||
|
value_local = value.split("@", 1)[0]
|
||||||
|
return value == target or value_local == target
|
||||||
|
|
||||||
|
def _extract_reply_hints(self, obj, max_depth: int = 6):
|
||||||
|
hints = []
|
||||||
|
|
||||||
|
def walk(value, path="", depth=0):
|
||||||
|
if depth > max_depth or value is None:
|
||||||
|
return
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key, nested in value.items():
|
||||||
|
key_str = str(key)
|
||||||
|
next_path = f"{path}.{key_str}" if path else key_str
|
||||||
|
lowered = key_str.lower()
|
||||||
|
if any(
|
||||||
|
token in lowered
|
||||||
|
for token in ("stanza", "quoted", "reply", "context")
|
||||||
|
):
|
||||||
|
if isinstance(nested, (str, int, float, bool)):
|
||||||
|
hints.append(
|
||||||
|
{
|
||||||
|
"path": next_path,
|
||||||
|
"value": str(nested)[:180],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
walk(nested, next_path, depth + 1)
|
||||||
|
return
|
||||||
|
if isinstance(value, list):
|
||||||
|
for idx, nested in enumerate(value):
|
||||||
|
walk(nested, f"{path}[{idx}]", depth + 1)
|
||||||
|
|
||||||
|
walk(obj, "", 0)
|
||||||
|
# Deduplicate by path/value for compact diagnostics.
|
||||||
|
unique = []
|
||||||
|
seen = set()
|
||||||
|
for row in hints:
|
||||||
|
key = (str(row.get("path") or ""), str(row.get("value") or ""))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
unique.append(row)
|
||||||
|
return unique[:40]
|
||||||
|
|
||||||
def _normalize_timestamp(self, raw_value):
|
def _normalize_timestamp(self, raw_value):
|
||||||
if raw_value is None:
|
if raw_value is None:
|
||||||
return int(time.time() * 1000)
|
return int(time.time() * 1000)
|
||||||
@@ -2361,19 +2436,22 @@ class WhatsAppClient(ClientBase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
async def _handle_message_event(self, event):
|
async def _handle_message_event(self, event):
|
||||||
msg_obj = self._pluck(event, "message") or self._pluck(event, "Message")
|
event_obj = self._proto_to_dict(event) or event
|
||||||
text = self._message_text(msg_obj, event)
|
msg_obj = self._pluck(event_obj, "message") or self._pluck(event_obj, "Message")
|
||||||
|
text = self._message_text(msg_obj, event_obj)
|
||||||
if not text:
|
if not text:
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
"whatsapp empty-text event shape: msg_keys=%s event_keys=%s type=%s",
|
"whatsapp empty-text event shape: msg_keys=%s event_keys=%s type=%s",
|
||||||
self._shape_keys(msg_obj),
|
self._shape_keys(msg_obj),
|
||||||
self._shape_keys(event),
|
self._shape_keys(event_obj),
|
||||||
str(type(event).__name__),
|
str(type(event).__name__),
|
||||||
)
|
)
|
||||||
source = (
|
source = (
|
||||||
self._pluck(event, "Info", "MessageSource")
|
self._pluck(event_obj, "Info", "MessageSource")
|
||||||
or self._pluck(event, "info", "message_source")
|
or self._pluck(event_obj, "info", "message_source")
|
||||||
or self._pluck(event, "info", "messageSource")
|
or self._pluck(event_obj, "info", "messageSource")
|
||||||
|
or self._pluck(event_obj, "info", "message_source")
|
||||||
|
or self._pluck(event_obj, "info", "messageSource")
|
||||||
)
|
)
|
||||||
is_from_me = bool(
|
is_from_me = bool(
|
||||||
self._pluck(source, "IsFromMe") or self._pluck(source, "isFromMe")
|
self._pluck(source, "IsFromMe") or self._pluck(source, "isFromMe")
|
||||||
@@ -2389,17 +2467,17 @@ class WhatsAppClient(ClientBase):
|
|||||||
self._pluck(source, "Chat") or self._pluck(source, "chat")
|
self._pluck(source, "Chat") or self._pluck(source, "chat")
|
||||||
)
|
)
|
||||||
raw_ts = (
|
raw_ts = (
|
||||||
self._pluck(event, "Info", "Timestamp")
|
self._pluck(event_obj, "Info", "Timestamp")
|
||||||
or self._pluck(event, "info", "timestamp")
|
or self._pluck(event_obj, "info", "timestamp")
|
||||||
or self._pluck(event, "info", "message_timestamp")
|
or self._pluck(event_obj, "info", "message_timestamp")
|
||||||
or self._pluck(event, "Timestamp")
|
or self._pluck(event_obj, "Timestamp")
|
||||||
or self._pluck(event, "timestamp")
|
or self._pluck(event_obj, "timestamp")
|
||||||
)
|
)
|
||||||
msg_id = str(
|
msg_id = str(
|
||||||
self._pluck(event, "Info", "ID")
|
self._pluck(event_obj, "Info", "ID")
|
||||||
or self._pluck(event, "info", "id")
|
or self._pluck(event_obj, "info", "id")
|
||||||
or self._pluck(event, "ID")
|
or self._pluck(event_obj, "ID")
|
||||||
or self._pluck(event, "id")
|
or self._pluck(event_obj, "id")
|
||||||
or ""
|
or ""
|
||||||
).strip()
|
).strip()
|
||||||
ts = self._normalize_timestamp(raw_ts)
|
ts = self._normalize_timestamp(raw_ts)
|
||||||
@@ -2529,12 +2607,196 @@ class WhatsAppClient(ClientBase):
|
|||||||
)()
|
)()
|
||||||
if duplicate_exists:
|
if duplicate_exists:
|
||||||
continue
|
continue
|
||||||
await history.store_message(
|
reply_ref = reply_sync.extract_reply_ref(
|
||||||
|
self.service,
|
||||||
|
{
|
||||||
|
"contextInfo": self._pluck(msg_obj, "contextInfo")
|
||||||
|
or self._pluck(msg_obj, "ContextInfo")
|
||||||
|
or self._pluck(msg_obj, "extendedTextMessage", "contextInfo")
|
||||||
|
or self._pluck(msg_obj, "ExtendedTextMessage", "ContextInfo")
|
||||||
|
or self._pluck(msg_obj, "imageMessage", "contextInfo")
|
||||||
|
or self._pluck(msg_obj, "ImageMessage", "ContextInfo")
|
||||||
|
or self._pluck(msg_obj, "videoMessage", "contextInfo")
|
||||||
|
or self._pluck(msg_obj, "VideoMessage", "ContextInfo")
|
||||||
|
or self._pluck(
|
||||||
|
msg_obj,
|
||||||
|
"ephemeralMessage",
|
||||||
|
"message",
|
||||||
|
"extendedTextMessage",
|
||||||
|
"contextInfo",
|
||||||
|
)
|
||||||
|
or self._pluck(
|
||||||
|
msg_obj,
|
||||||
|
"EphemeralMessage",
|
||||||
|
"Message",
|
||||||
|
"ExtendedTextMessage",
|
||||||
|
"ContextInfo",
|
||||||
|
)
|
||||||
|
or self._pluck(
|
||||||
|
msg_obj,
|
||||||
|
"viewOnceMessage",
|
||||||
|
"message",
|
||||||
|
"extendedTextMessage",
|
||||||
|
"contextInfo",
|
||||||
|
)
|
||||||
|
or self._pluck(
|
||||||
|
msg_obj,
|
||||||
|
"ViewOnceMessage",
|
||||||
|
"Message",
|
||||||
|
"ExtendedTextMessage",
|
||||||
|
"ContextInfo",
|
||||||
|
)
|
||||||
|
or self._pluck(
|
||||||
|
msg_obj,
|
||||||
|
"viewOnceMessageV2",
|
||||||
|
"message",
|
||||||
|
"extendedTextMessage",
|
||||||
|
"contextInfo",
|
||||||
|
)
|
||||||
|
or self._pluck(
|
||||||
|
msg_obj,
|
||||||
|
"ViewOnceMessageV2",
|
||||||
|
"Message",
|
||||||
|
"ExtendedTextMessage",
|
||||||
|
"ContextInfo",
|
||||||
|
)
|
||||||
|
or self._pluck(
|
||||||
|
msg_obj,
|
||||||
|
"viewOnceMessageV2Extension",
|
||||||
|
"message",
|
||||||
|
"extendedTextMessage",
|
||||||
|
"contextInfo",
|
||||||
|
)
|
||||||
|
or self._pluck(
|
||||||
|
msg_obj,
|
||||||
|
"ViewOnceMessageV2Extension",
|
||||||
|
"Message",
|
||||||
|
"ExtendedTextMessage",
|
||||||
|
"ContextInfo",
|
||||||
|
)
|
||||||
|
or {},
|
||||||
|
"messageContextInfo": self._pluck(msg_obj, "messageContextInfo")
|
||||||
|
or self._pluck(msg_obj, "MessageContextInfo")
|
||||||
|
or {},
|
||||||
|
"message": {
|
||||||
|
"extendedTextMessage": self._pluck(msg_obj, "extendedTextMessage")
|
||||||
|
or self._pluck(msg_obj, "ExtendedTextMessage")
|
||||||
|
or {},
|
||||||
|
"imageMessage": self._pluck(msg_obj, "imageMessage") or {},
|
||||||
|
"ImageMessage": self._pluck(msg_obj, "ImageMessage") or {},
|
||||||
|
"videoMessage": self._pluck(msg_obj, "videoMessage") or {},
|
||||||
|
"VideoMessage": self._pluck(msg_obj, "VideoMessage") or {},
|
||||||
|
"documentMessage": self._pluck(msg_obj, "documentMessage")
|
||||||
|
or {},
|
||||||
|
"DocumentMessage": self._pluck(msg_obj, "DocumentMessage")
|
||||||
|
or {},
|
||||||
|
"ephemeralMessage": self._pluck(msg_obj, "ephemeralMessage")
|
||||||
|
or {},
|
||||||
|
"EphemeralMessage": self._pluck(msg_obj, "EphemeralMessage")
|
||||||
|
or {},
|
||||||
|
"viewOnceMessage": self._pluck(msg_obj, "viewOnceMessage")
|
||||||
|
or {},
|
||||||
|
"ViewOnceMessage": self._pluck(msg_obj, "ViewOnceMessage")
|
||||||
|
or {},
|
||||||
|
"viewOnceMessageV2": self._pluck(msg_obj, "viewOnceMessageV2")
|
||||||
|
or {},
|
||||||
|
"ViewOnceMessageV2": self._pluck(msg_obj, "ViewOnceMessageV2")
|
||||||
|
or {},
|
||||||
|
"viewOnceMessageV2Extension": self._pluck(
|
||||||
|
msg_obj, "viewOnceMessageV2Extension"
|
||||||
|
)
|
||||||
|
or {},
|
||||||
|
"ViewOnceMessageV2Extension": self._pluck(
|
||||||
|
msg_obj, "ViewOnceMessageV2Extension"
|
||||||
|
)
|
||||||
|
or {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
reply_debug = {}
|
||||||
|
if self._chat_matches_reply_debug(chat):
|
||||||
|
reply_debug = reply_sync.extract_whatsapp_reply_debug(
|
||||||
|
{
|
||||||
|
"contextInfo": self._pluck(msg_obj, "contextInfo") or {},
|
||||||
|
"messageContextInfo": self._pluck(msg_obj, "messageContextInfo")
|
||||||
|
or {},
|
||||||
|
"message": {
|
||||||
|
"extendedTextMessage": self._pluck(
|
||||||
|
msg_obj, "extendedTextMessage"
|
||||||
|
)
|
||||||
|
or {},
|
||||||
|
"imageMessage": self._pluck(msg_obj, "imageMessage") or {},
|
||||||
|
"videoMessage": self._pluck(msg_obj, "videoMessage") or {},
|
||||||
|
"documentMessage": self._pluck(msg_obj, "documentMessage")
|
||||||
|
or {},
|
||||||
|
"ephemeralMessage": self._pluck(msg_obj, "ephemeralMessage")
|
||||||
|
or {},
|
||||||
|
"viewOnceMessage": self._pluck(msg_obj, "viewOnceMessage")
|
||||||
|
or {},
|
||||||
|
"viewOnceMessageV2": self._pluck(msg_obj, "viewOnceMessageV2")
|
||||||
|
or {},
|
||||||
|
"viewOnceMessageV2Extension": self._pluck(
|
||||||
|
msg_obj, "viewOnceMessageV2Extension"
|
||||||
|
)
|
||||||
|
or {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.log.warning(
|
||||||
|
"wa-reply-debug chat=%s msg_id=%s reply_ref=%s debug=%s",
|
||||||
|
str(chat or ""),
|
||||||
|
str(msg_id or ""),
|
||||||
|
json.dumps(reply_ref, ensure_ascii=True),
|
||||||
|
json.dumps(reply_debug, ensure_ascii=True),
|
||||||
|
)
|
||||||
|
reply_target = await reply_sync.resolve_reply_target(
|
||||||
|
identifier.user,
|
||||||
|
session,
|
||||||
|
reply_ref,
|
||||||
|
)
|
||||||
|
message_meta = reply_sync.apply_sync_origin(
|
||||||
|
{},
|
||||||
|
reply_sync.extract_origin_tag(payload),
|
||||||
|
)
|
||||||
|
if self._chat_matches_reply_debug(chat):
|
||||||
|
info_obj = self._proto_to_dict(self._pluck(event_obj, "Info")) or self._pluck(
|
||||||
|
event_obj, "Info"
|
||||||
|
)
|
||||||
|
raw_obj = self._proto_to_dict(self._pluck(event_obj, "Raw")) or self._pluck(
|
||||||
|
event_obj, "Raw"
|
||||||
|
)
|
||||||
|
message_meta["wa_reply_debug"] = {
|
||||||
|
"reply_ref": reply_ref,
|
||||||
|
"reply_target_id": str(getattr(reply_target, "id", "") or ""),
|
||||||
|
"msg_id": str(msg_id or ""),
|
||||||
|
"chat": str(chat or ""),
|
||||||
|
"sender": str(sender or ""),
|
||||||
|
"msg_obj_keys": self._shape_keys(msg_obj),
|
||||||
|
"event_keys": self._shape_keys(event_obj),
|
||||||
|
"info_keys": self._shape_keys(info_obj),
|
||||||
|
"raw_keys": self._shape_keys(raw_obj),
|
||||||
|
"event_type": str(type(event).__name__),
|
||||||
|
"reply_hints_event": self._extract_reply_hints(event_obj),
|
||||||
|
"reply_hints_message": self._extract_reply_hints(msg_obj),
|
||||||
|
"reply_hints_info": self._extract_reply_hints(info_obj),
|
||||||
|
"reply_hints_raw": self._extract_reply_hints(raw_obj),
|
||||||
|
"debug": reply_debug,
|
||||||
|
}
|
||||||
|
local_message = await history.store_message(
|
||||||
session=session,
|
session=session,
|
||||||
sender=str(sender or chat or ""),
|
sender=str(sender or chat or ""),
|
||||||
text=display_text,
|
text=display_text,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
outgoing=is_from_me,
|
outgoing=is_from_me,
|
||||||
|
source_service=self.service,
|
||||||
|
source_message_id=str(msg_id or ""),
|
||||||
|
source_chat_id=str(chat or sender or ""),
|
||||||
|
reply_to=reply_target,
|
||||||
|
reply_source_service=str(reply_ref.get("reply_source_service") or ""),
|
||||||
|
reply_source_message_id=str(
|
||||||
|
reply_ref.get("reply_source_message_id") or ""
|
||||||
|
),
|
||||||
|
message_meta=message_meta,
|
||||||
)
|
)
|
||||||
await self.ur.message_received(
|
await self.ur.message_received(
|
||||||
self.service,
|
self.service,
|
||||||
@@ -2542,6 +2804,7 @@ class WhatsAppClient(ClientBase):
|
|||||||
text=display_text,
|
text=display_text,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
|
local_message=local_message,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _handle_receipt_event(self, event):
|
async def _handle_receipt_event(self, event):
|
||||||
@@ -2679,6 +2942,11 @@ class WhatsAppClient(ClientBase):
|
|||||||
return ""
|
return ""
|
||||||
if "@" in raw:
|
if "@" in raw:
|
||||||
return raw
|
return raw
|
||||||
|
# Group chats often arrive as bare numeric ids in compose/runtime
|
||||||
|
# payloads; prefer known group mappings before defaulting to person JIDs.
|
||||||
|
group_jid = self._resolve_group_jid(raw)
|
||||||
|
if group_jid:
|
||||||
|
return group_jid
|
||||||
digits = re.sub(r"[^0-9]", "", raw)
|
digits = re.sub(r"[^0-9]", "", raw)
|
||||||
if digits:
|
if digits:
|
||||||
# Prefer direct JID formatting for phone numbers; Neonize build_jid
|
# Prefer direct JID formatting for phone numbers; Neonize build_jid
|
||||||
@@ -2691,6 +2959,66 @@ class WhatsAppClient(ClientBase):
|
|||||||
pass
|
pass
|
||||||
return raw
|
return raw
|
||||||
|
|
||||||
|
def _resolve_group_jid(self, value: str) -> str:
|
||||||
|
local = str(value or "").strip().split("@", 1)[0].strip()
|
||||||
|
if not local:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Runtime state is the cheapest source of truth for currently joined groups.
|
||||||
|
state = transport.get_runtime_state(self.service) or {}
|
||||||
|
for row in list(state.get("groups") or []):
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
candidates = (
|
||||||
|
row.get("identifier"),
|
||||||
|
row.get("chat_identifier"),
|
||||||
|
row.get("chat"),
|
||||||
|
row.get("jid"),
|
||||||
|
row.get("chat_jid"),
|
||||||
|
)
|
||||||
|
matched = False
|
||||||
|
for candidate in candidates:
|
||||||
|
candidate_local = str(self._jid_to_identifier(candidate) or "").split(
|
||||||
|
"@", 1
|
||||||
|
)[0].strip()
|
||||||
|
if candidate_local and candidate_local == local:
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
if not matched:
|
||||||
|
continue
|
||||||
|
jid = str(
|
||||||
|
self._jid_to_identifier(row.get("jid") or row.get("chat_jid") or "")
|
||||||
|
).strip()
|
||||||
|
if jid and "@g.us" in jid:
|
||||||
|
return jid
|
||||||
|
return f"{local}@g.us"
|
||||||
|
|
||||||
|
# DB fallback for compose pages that resolved from PlatformChatLink.
|
||||||
|
try:
|
||||||
|
link = (
|
||||||
|
PlatformChatLink.objects.filter(
|
||||||
|
service="whatsapp",
|
||||||
|
chat_identifier=local,
|
||||||
|
is_group=True,
|
||||||
|
)
|
||||||
|
.order_by("-updated_at", "-id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
link = None
|
||||||
|
if link is not None:
|
||||||
|
jid = str(self._jid_to_identifier(link.chat_jid or "")).strip()
|
||||||
|
if jid and "@g.us" in jid:
|
||||||
|
return jid
|
||||||
|
return f"{local}@g.us"
|
||||||
|
|
||||||
|
# WhatsApp group ids are numeric and usually very long (commonly start
|
||||||
|
# with 120...). Treat those as groups when no explicit mapping exists.
|
||||||
|
digits = re.sub(r"[^0-9]", "", local)
|
||||||
|
if digits and digits == local and len(digits) >= 15 and digits.startswith("120"):
|
||||||
|
return f"{digits}@g.us"
|
||||||
|
return ""
|
||||||
|
|
||||||
def _blob_key_to_compose_url(self, blob_key):
|
def _blob_key_to_compose_url(self, blob_key):
|
||||||
key = str(blob_key or "").strip()
|
key = str(blob_key or "").strip()
|
||||||
if not key:
|
if not key:
|
||||||
@@ -2806,8 +3134,31 @@ class WhatsAppClient(ClientBase):
|
|||||||
metadata = dict(metadata or {})
|
metadata = dict(metadata or {})
|
||||||
xmpp_source_id = str(metadata.get("xmpp_source_id") or "").strip()
|
xmpp_source_id = str(metadata.get("xmpp_source_id") or "").strip()
|
||||||
legacy_message_id = str(metadata.get("legacy_message_id") or "").strip()
|
legacy_message_id = str(metadata.get("legacy_message_id") or "").strip()
|
||||||
|
reply_to_upstream_message_id = str(
|
||||||
|
metadata.get("reply_to_upstream_message_id") or ""
|
||||||
|
).strip()
|
||||||
|
reply_to_participant = str(metadata.get("reply_to_participant") or "").strip()
|
||||||
|
reply_to_remote_jid = str(metadata.get("reply_to_remote_jid") or "").strip()
|
||||||
person_identifier = None
|
person_identifier = None
|
||||||
if xmpp_source_id:
|
if legacy_message_id:
|
||||||
|
person_identifier = await sync_to_async(
|
||||||
|
lambda: (
|
||||||
|
Message.objects.filter(id=legacy_message_id)
|
||||||
|
.select_related("session__identifier__user", "session__identifier__person")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
)()
|
||||||
|
if person_identifier is not None:
|
||||||
|
person_identifier = getattr(
|
||||||
|
getattr(person_identifier, "session", None), "identifier", None
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
person_identifier is not None
|
||||||
|
and str(getattr(person_identifier, "service", "") or "").strip().lower()
|
||||||
|
!= "whatsapp"
|
||||||
|
):
|
||||||
|
person_identifier = None
|
||||||
|
if person_identifier is None and (xmpp_source_id or legacy_message_id):
|
||||||
candidates = list(self._normalize_identifier_candidates(recipient, jid_str))
|
candidates = list(self._normalize_identifier_candidates(recipient, jid_str))
|
||||||
if candidates:
|
if candidates:
|
||||||
person_identifier = await sync_to_async(
|
person_identifier = await sync_to_async(
|
||||||
@@ -2828,8 +3179,25 @@ class WhatsAppClient(ClientBase):
|
|||||||
or ""
|
or ""
|
||||||
).strip()
|
).strip()
|
||||||
|
|
||||||
def _record_bridge(response, ts_value, body_hint=""):
|
async def _record_bridge(response, ts_value, body_hint=""):
|
||||||
if not xmpp_source_id or person_identifier is None:
|
if person_identifier is None:
|
||||||
|
return
|
||||||
|
upstream_message_id = _extract_response_message_id(response)
|
||||||
|
if legacy_message_id:
|
||||||
|
try:
|
||||||
|
await history.save_bridge_ref(
|
||||||
|
person_identifier.user,
|
||||||
|
person_identifier,
|
||||||
|
source_service="whatsapp",
|
||||||
|
local_message_id=legacy_message_id,
|
||||||
|
local_ts=int(ts_value or int(time.time() * 1000)),
|
||||||
|
upstream_message_id=upstream_message_id,
|
||||||
|
upstream_author=str(recipient or ""),
|
||||||
|
upstream_ts=int(ts_value or 0),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not xmpp_source_id:
|
||||||
return
|
return
|
||||||
transport.record_bridge_mapping(
|
transport.record_bridge_mapping(
|
||||||
user_id=person_identifier.user_id,
|
user_id=person_identifier.user_id,
|
||||||
@@ -2837,7 +3205,7 @@ class WhatsAppClient(ClientBase):
|
|||||||
service="whatsapp",
|
service="whatsapp",
|
||||||
xmpp_message_id=xmpp_source_id,
|
xmpp_message_id=xmpp_source_id,
|
||||||
xmpp_ts=int(metadata.get("xmpp_source_ts") or 0),
|
xmpp_ts=int(metadata.get("xmpp_source_ts") or 0),
|
||||||
upstream_message_id=_extract_response_message_id(response),
|
upstream_message_id=upstream_message_id,
|
||||||
upstream_ts=int(ts_value or 0),
|
upstream_ts=int(ts_value or 0),
|
||||||
text_preview=str(body_hint or metadata.get("xmpp_body") or ""),
|
text_preview=str(body_hint or metadata.get("xmpp_body") or ""),
|
||||||
local_message_id=legacy_message_id,
|
local_message_id=legacy_message_id,
|
||||||
@@ -2899,7 +3267,7 @@ class WhatsAppClient(ClientBase):
|
|||||||
sent_ts,
|
sent_ts,
|
||||||
self._normalize_timestamp(self._pluck(response, "Timestamp") or 0),
|
self._normalize_timestamp(self._pluck(response, "Timestamp") or 0),
|
||||||
)
|
)
|
||||||
_record_bridge(response, sent_ts, body_hint=filename)
|
await _record_bridge(response, sent_ts, body_hint=filename)
|
||||||
sent_any = True
|
sent_any = True
|
||||||
if getattr(settings, "WHATSAPP_DEBUG", False):
|
if getattr(settings, "WHATSAPP_DEBUG", False):
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
@@ -2916,6 +3284,35 @@ class WhatsAppClient(ClientBase):
|
|||||||
if text:
|
if text:
|
||||||
response = None
|
response = None
|
||||||
last_error = None
|
last_error = None
|
||||||
|
quoted_text_message = text
|
||||||
|
if reply_to_upstream_message_id:
|
||||||
|
try:
|
||||||
|
from neonize.proto.waE2E.WAWebProtobufsE2E_pb2 import (
|
||||||
|
ContextInfo,
|
||||||
|
ExtendedTextMessage,
|
||||||
|
Message as WAProtoMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
context = ContextInfo(
|
||||||
|
stanzaID=reply_to_upstream_message_id,
|
||||||
|
)
|
||||||
|
participant_jid = self._to_jid(reply_to_participant)
|
||||||
|
remote_jid = self._to_jid(reply_to_remote_jid) or jid_str
|
||||||
|
if participant_jid:
|
||||||
|
context.participant = participant_jid
|
||||||
|
if remote_jid:
|
||||||
|
context.remoteJID = remote_jid
|
||||||
|
quoted_text_message = WAProtoMessage(
|
||||||
|
extendedTextMessage=ExtendedTextMessage(
|
||||||
|
text=str(text or ""),
|
||||||
|
contextInfo=context,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.warning(
|
||||||
|
"whatsapp quoted-reply payload build failed: %s", exc
|
||||||
|
)
|
||||||
|
quoted_text_message = text
|
||||||
# Prepare cancel key (if caller provided command_id)
|
# Prepare cancel key (if caller provided command_id)
|
||||||
cancel_key = None
|
cancel_key = None
|
||||||
try:
|
try:
|
||||||
@@ -2945,7 +3342,7 @@ class WhatsAppClient(ClientBase):
|
|||||||
response = await self._call_client_method(
|
response = await self._call_client_method(
|
||||||
getattr(self._client, "send_message", None),
|
getattr(self._client, "send_message", None),
|
||||||
send_target,
|
send_target,
|
||||||
text,
|
quoted_text_message,
|
||||||
timeout=9.0,
|
timeout=9.0,
|
||||||
)
|
)
|
||||||
sent_any = True
|
sent_any = True
|
||||||
@@ -3030,7 +3427,7 @@ class WhatsAppClient(ClientBase):
|
|||||||
sent_ts,
|
sent_ts,
|
||||||
self._normalize_timestamp(self._pluck(response, "Timestamp") or 0),
|
self._normalize_timestamp(self._pluck(response, "Timestamp") or 0),
|
||||||
)
|
)
|
||||||
_record_bridge(response, sent_ts, body_hint=str(text or ""))
|
await _record_bridge(response, sent_ts, body_hint=str(text or ""))
|
||||||
|
|
||||||
if not sent_any:
|
if not sent_any:
|
||||||
self._last_send_error = "no_payload_sent"
|
self._last_send_error = "no_payload_sent"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from slixmpp.xmlstream import register_stanza_plugin
|
|||||||
from slixmpp.xmlstream.stanzabase import ET
|
from slixmpp.xmlstream.stanzabase import ET
|
||||||
|
|
||||||
from core.clients import ClientBase, transport
|
from core.clients import ClientBase, transport
|
||||||
from core.messaging import ai, history, replies, utils
|
from core.messaging import ai, history, replies, reply_sync, utils
|
||||||
from core.models import (
|
from core.models import (
|
||||||
ChatSession,
|
ChatSession,
|
||||||
Manipulation,
|
Manipulation,
|
||||||
@@ -1236,14 +1236,46 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
user=identifier.user,
|
user=identifier.user,
|
||||||
)
|
)
|
||||||
self.log.debug("Storing outbound XMPP message in history")
|
self.log.debug("Storing outbound XMPP message in history")
|
||||||
|
reply_ref = reply_sync.extract_reply_ref(
|
||||||
|
"xmpp",
|
||||||
|
{
|
||||||
|
"reply_source_message_id": parsed_reply_target,
|
||||||
|
"reply_source_chat_id": str(sender_jid or ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
reply_target = await reply_sync.resolve_reply_target(
|
||||||
|
identifier.user,
|
||||||
|
session,
|
||||||
|
reply_ref,
|
||||||
|
)
|
||||||
local_message = await history.store_message(
|
local_message = await history.store_message(
|
||||||
session=session,
|
session=session,
|
||||||
sender="XMPP",
|
sender="XMPP",
|
||||||
text=body,
|
text=body,
|
||||||
ts=int(now().timestamp() * 1000),
|
ts=int(now().timestamp() * 1000),
|
||||||
outgoing=True,
|
outgoing=True,
|
||||||
|
source_service="xmpp",
|
||||||
|
source_message_id=xmpp_message_id,
|
||||||
|
source_chat_id=str(sender_jid or ""),
|
||||||
|
reply_to=reply_target,
|
||||||
|
reply_source_service=str(reply_ref.get("reply_source_service") or ""),
|
||||||
|
reply_source_message_id=str(
|
||||||
|
reply_ref.get("reply_source_message_id") or ""
|
||||||
|
),
|
||||||
|
message_meta={},
|
||||||
)
|
)
|
||||||
self.log.debug("Stored outbound XMPP message in history")
|
self.log.debug("Stored outbound XMPP message in history")
|
||||||
|
await self.ur.message_received(
|
||||||
|
"xmpp",
|
||||||
|
identifier=identifier,
|
||||||
|
text=body,
|
||||||
|
ts=int(now().timestamp() * 1000),
|
||||||
|
payload={
|
||||||
|
"sender_jid": sender_jid,
|
||||||
|
"recipient_jid": recipient_jid,
|
||||||
|
},
|
||||||
|
local_message=local_message,
|
||||||
|
)
|
||||||
|
|
||||||
manipulations = Manipulation.objects.filter(
|
manipulations = Manipulation.objects.filter(
|
||||||
group__people=identifier.person,
|
group__people=identifier.person,
|
||||||
|
|||||||
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
|
return chat_session
|
||||||
|
|
||||||
|
|
||||||
async def store_message(session, sender, text, ts, outgoing=False):
|
async def store_message(
|
||||||
|
session,
|
||||||
|
sender,
|
||||||
|
text,
|
||||||
|
ts,
|
||||||
|
outgoing=False,
|
||||||
|
source_service="",
|
||||||
|
source_message_id="",
|
||||||
|
source_chat_id="",
|
||||||
|
reply_to=None,
|
||||||
|
reply_source_service="",
|
||||||
|
reply_source_message_id="",
|
||||||
|
message_meta=None,
|
||||||
|
):
|
||||||
log.debug("Storing message for session=%s outgoing=%s", session.id, outgoing)
|
log.debug("Storing message for session=%s outgoing=%s", session.id, outgoing)
|
||||||
msg = await sync_to_async(Message.objects.create)(
|
msg = await sync_to_async(Message.objects.create)(
|
||||||
user=session.user,
|
user=session.user,
|
||||||
@@ -154,12 +167,32 @@ async def store_message(session, sender, text, ts, outgoing=False):
|
|||||||
ts=ts,
|
ts=ts,
|
||||||
delivered_ts=ts,
|
delivered_ts=ts,
|
||||||
custom_author="USER" if outgoing else None,
|
custom_author="USER" if outgoing else None,
|
||||||
|
source_service=(source_service or None),
|
||||||
|
source_message_id=str(source_message_id or "").strip() or None,
|
||||||
|
source_chat_id=str(source_chat_id or "").strip() or None,
|
||||||
|
reply_to=reply_to,
|
||||||
|
reply_source_service=str(reply_source_service or "").strip() or None,
|
||||||
|
reply_source_message_id=str(reply_source_message_id or "").strip() or None,
|
||||||
|
message_meta=dict(message_meta or {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
async def store_own_message(session, text, ts, manip=None, queue=False):
|
async def store_own_message(
|
||||||
|
session,
|
||||||
|
text,
|
||||||
|
ts,
|
||||||
|
manip=None,
|
||||||
|
queue=False,
|
||||||
|
source_service="",
|
||||||
|
source_message_id="",
|
||||||
|
source_chat_id="",
|
||||||
|
reply_to=None,
|
||||||
|
reply_source_service="",
|
||||||
|
reply_source_message_id="",
|
||||||
|
message_meta=None,
|
||||||
|
):
|
||||||
log.debug("Storing own message for session=%s queue=%s", session.id, queue)
|
log.debug("Storing own message for session=%s queue=%s", session.id, queue)
|
||||||
cast = {
|
cast = {
|
||||||
"user": session.user,
|
"user": session.user,
|
||||||
@@ -168,6 +201,13 @@ async def store_own_message(session, text, ts, manip=None, queue=False):
|
|||||||
"text": text,
|
"text": text,
|
||||||
"ts": ts,
|
"ts": ts,
|
||||||
"delivered_ts": ts,
|
"delivered_ts": ts,
|
||||||
|
"source_service": (source_service or None),
|
||||||
|
"source_message_id": str(source_message_id or "").strip() or None,
|
||||||
|
"source_chat_id": str(source_chat_id or "").strip() or None,
|
||||||
|
"reply_to": reply_to,
|
||||||
|
"reply_source_service": str(reply_source_service or "").strip() or None,
|
||||||
|
"reply_source_message_id": str(reply_source_message_id or "").strip() or None,
|
||||||
|
"message_meta": dict(message_meta or {}),
|
||||||
}
|
}
|
||||||
if queue:
|
if queue:
|
||||||
msg_object = QueuedMessage
|
msg_object = QueuedMessage
|
||||||
|
|||||||
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"),
|
("xmpp", "XMPP"),
|
||||||
("instagram", "Instagram"),
|
("instagram", "Instagram"),
|
||||||
)
|
)
|
||||||
|
CHANNEL_SERVICE_CHOICES = SERVICE_CHOICES + (("web", "Web"),)
|
||||||
MBTI_CHOICES = (
|
MBTI_CHOICES = (
|
||||||
("INTJ", "INTJ - Architect"),
|
("INTJ", "INTJ - Architect"),
|
||||||
("INTP", "INTP - Logician"),
|
("INTP", "INTP - Logician"),
|
||||||
@@ -297,9 +298,61 @@ class Message(models.Model):
|
|||||||
blank=True,
|
blank=True,
|
||||||
help_text="Raw normalized delivery/read receipt metadata.",
|
help_text="Raw normalized delivery/read receipt metadata.",
|
||||||
)
|
)
|
||||||
|
source_service = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
choices=CHANNEL_SERVICE_CHOICES,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Source service where this message originally appeared.",
|
||||||
|
)
|
||||||
|
source_message_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Source service message id when available.",
|
||||||
|
)
|
||||||
|
source_chat_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Source service chat or thread identifier when available.",
|
||||||
|
)
|
||||||
|
reply_to = models.ForeignKey(
|
||||||
|
"self",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="reply_children",
|
||||||
|
help_text="Resolved local message this message replies to.",
|
||||||
|
)
|
||||||
|
reply_source_service = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
choices=CHANNEL_SERVICE_CHOICES,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Source service for the replied target.",
|
||||||
|
)
|
||||||
|
reply_source_message_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Source message id for the replied target.",
|
||||||
|
)
|
||||||
|
message_meta = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
blank=True,
|
||||||
|
help_text="Normalized message metadata such as origin tags.",
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["ts"]
|
ordering = ["ts"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "source_service", "source_message_id"]),
|
||||||
|
models.Index(fields=["user", "session", "ts"]),
|
||||||
|
models.Index(
|
||||||
|
fields=["user", "reply_source_service", "reply_source_message_id"]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class Group(models.Model):
|
class Group(models.Model):
|
||||||
@@ -1568,6 +1621,270 @@ class PatternArtifactExport(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CommandProfile(models.Model):
|
||||||
|
WINDOW_SCOPE_CHOICES = (
|
||||||
|
("conversation", "Conversation"),
|
||||||
|
)
|
||||||
|
VISIBILITY_CHOICES = (
|
||||||
|
("status_in_source", "Status In Source"),
|
||||||
|
("silent", "Silent"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
slug = models.CharField(max_length=64, default="bp")
|
||||||
|
name = models.CharField(max_length=255, default="Business Plan")
|
||||||
|
enabled = models.BooleanField(default=True)
|
||||||
|
trigger_token = models.CharField(max_length=64, default="#bp#")
|
||||||
|
reply_required = models.BooleanField(default=True)
|
||||||
|
exact_match_only = models.BooleanField(default=True)
|
||||||
|
window_scope = models.CharField(
|
||||||
|
max_length=64,
|
||||||
|
choices=WINDOW_SCOPE_CHOICES,
|
||||||
|
default="conversation",
|
||||||
|
)
|
||||||
|
template_text = models.TextField(blank=True, default="")
|
||||||
|
visibility_mode = models.CharField(
|
||||||
|
max_length=64,
|
||||||
|
choices=VISIBILITY_CHOICES,
|
||||||
|
default="status_in_source",
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "slug"],
|
||||||
|
name="unique_command_profile_per_user",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user_id}:{self.slug}"
|
||||||
|
|
||||||
|
|
||||||
|
class CommandChannelBinding(models.Model):
|
||||||
|
DIRECTION_CHOICES = (
|
||||||
|
("ingress", "Ingress"),
|
||||||
|
("egress", "Egress"),
|
||||||
|
("scratchpad_mirror", "Scratchpad Mirror"),
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = models.ForeignKey(
|
||||||
|
CommandProfile,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="channel_bindings",
|
||||||
|
)
|
||||||
|
direction = models.CharField(max_length=64, choices=DIRECTION_CHOICES)
|
||||||
|
service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
|
||||||
|
channel_identifier = models.CharField(max_length=255)
|
||||||
|
enabled = models.BooleanField(default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["profile", "direction", "service"]),
|
||||||
|
models.Index(fields=["profile", "service", "channel_identifier"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CommandAction(models.Model):
|
||||||
|
ACTION_CHOICES = (
|
||||||
|
("extract_bp", "Extract Business Plan"),
|
||||||
|
("post_result", "Post Result"),
|
||||||
|
("save_document", "Save Document"),
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = models.ForeignKey(
|
||||||
|
CommandProfile,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="actions",
|
||||||
|
)
|
||||||
|
action_type = models.CharField(max_length=64, choices=ACTION_CHOICES)
|
||||||
|
enabled = models.BooleanField(default=True)
|
||||||
|
config = models.JSONField(default=dict, blank=True)
|
||||||
|
position = models.PositiveIntegerField(default=0)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["position", "id"]
|
||||||
|
indexes = [models.Index(fields=["profile", "action_type", "enabled"])]
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessPlanDocument(models.Model):
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
("draft", "Draft"),
|
||||||
|
("final", "Final"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
command_profile = models.ForeignKey(
|
||||||
|
CommandProfile,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="business_plan_documents",
|
||||||
|
)
|
||||||
|
source_service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
|
||||||
|
source_channel_identifier = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
trigger_message = models.ForeignKey(
|
||||||
|
Message,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="business_plan_trigger_docs",
|
||||||
|
)
|
||||||
|
anchor_message = models.ForeignKey(
|
||||||
|
Message,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="business_plan_anchor_docs",
|
||||||
|
)
|
||||||
|
title = models.CharField(max_length=255, default="Business Plan")
|
||||||
|
status = models.CharField(max_length=32, choices=STATUS_CHOICES, default="draft")
|
||||||
|
content_markdown = models.TextField(blank=True, default="")
|
||||||
|
structured_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "status", "updated_at"]),
|
||||||
|
models.Index(fields=["user", "source_service", "source_channel_identifier"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessPlanRevision(models.Model):
|
||||||
|
document = models.ForeignKey(
|
||||||
|
BusinessPlanDocument,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="revisions",
|
||||||
|
)
|
||||||
|
editor_user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
content_markdown = models.TextField(blank=True, default="")
|
||||||
|
structured_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["created_at"]
|
||||||
|
|
||||||
|
|
||||||
|
class CommandRun(models.Model):
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
("pending", "Pending"),
|
||||||
|
("running", "Running"),
|
||||||
|
("ok", "OK"),
|
||||||
|
("failed", "Failed"),
|
||||||
|
("skipped", "Skipped"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
profile = models.ForeignKey(
|
||||||
|
CommandProfile,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="runs",
|
||||||
|
)
|
||||||
|
trigger_message = models.ForeignKey(
|
||||||
|
Message,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="command_runs",
|
||||||
|
)
|
||||||
|
status = models.CharField(max_length=32, choices=STATUS_CHOICES, default="pending")
|
||||||
|
error = models.TextField(blank=True, default="")
|
||||||
|
result_ref = models.ForeignKey(
|
||||||
|
BusinessPlanDocument,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="command_runs",
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["profile", "trigger_message"],
|
||||||
|
name="unique_command_run_profile_trigger_message",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
indexes = [models.Index(fields=["user", "status", "updated_at"])]
|
||||||
|
|
||||||
|
|
||||||
|
class TranslationBridge(models.Model):
|
||||||
|
DIRECTION_CHOICES = (
|
||||||
|
("a_to_b", "A To B"),
|
||||||
|
("b_to_a", "B To A"),
|
||||||
|
("bidirectional", "Bidirectional"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
name = models.CharField(max_length=255, default="Translation Bridge")
|
||||||
|
enabled = models.BooleanField(default=True)
|
||||||
|
a_service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
|
||||||
|
a_channel_identifier = models.CharField(max_length=255)
|
||||||
|
a_language = models.CharField(max_length=64, default="en")
|
||||||
|
b_service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
|
||||||
|
b_channel_identifier = models.CharField(max_length=255)
|
||||||
|
b_language = models.CharField(max_length=64, default="en")
|
||||||
|
direction = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
choices=DIRECTION_CHOICES,
|
||||||
|
default="bidirectional",
|
||||||
|
)
|
||||||
|
quick_mode_title = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
settings = models.JSONField(default=dict, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "enabled"]),
|
||||||
|
models.Index(fields=["user", "a_service", "a_channel_identifier"]),
|
||||||
|
models.Index(fields=["user", "b_service", "b_channel_identifier"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TranslationEventLog(models.Model):
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
("pending", "Pending"),
|
||||||
|
("ok", "OK"),
|
||||||
|
("failed", "Failed"),
|
||||||
|
("skipped", "Skipped"),
|
||||||
|
)
|
||||||
|
|
||||||
|
bridge = models.ForeignKey(
|
||||||
|
TranslationBridge,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="events",
|
||||||
|
)
|
||||||
|
source_message = models.ForeignKey(
|
||||||
|
Message,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="translation_events",
|
||||||
|
)
|
||||||
|
target_service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
|
||||||
|
target_channel = models.CharField(max_length=255)
|
||||||
|
status = models.CharField(max_length=32, choices=STATUS_CHOICES, default="pending")
|
||||||
|
error = models.TextField(blank=True, default="")
|
||||||
|
origin_tag = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
content_hash = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["bridge", "created_at"]),
|
||||||
|
models.Index(fields=["bridge", "status", "updated_at"]),
|
||||||
|
models.Index(fields=["origin_tag"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# class Perms(models.Model):
|
# class Perms(models.Model):
|
||||||
# class Meta:
|
# class Meta:
|
||||||
# permissions = (
|
# permissions = (
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ from core.clients.instagram import InstagramClient
|
|||||||
from core.clients.signal import SignalClient
|
from core.clients.signal import SignalClient
|
||||||
from core.clients.whatsapp import WhatsAppClient
|
from core.clients.whatsapp import WhatsAppClient
|
||||||
from core.clients.xmpp import XMPPClient
|
from core.clients.xmpp import XMPPClient
|
||||||
|
from core.commands.base import CommandContext
|
||||||
|
from core.commands.engine import process_inbound_message
|
||||||
from core.messaging import history
|
from core.messaging import history
|
||||||
from core.models import PersonIdentifier
|
from core.models import PersonIdentifier
|
||||||
from core.realtime.typing_state import set_person_typing_state
|
from core.realtime.typing_state import set_person_typing_state
|
||||||
|
from core.translation.engine import process_inbound_translation
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +94,34 @@ class UnifiedRouter(object):
|
|||||||
|
|
||||||
async def message_received(self, protocol, *args, **kwargs):
|
async def message_received(self, protocol, *args, **kwargs):
|
||||||
self.log.info(f"Message received ({protocol}) {args} {kwargs}")
|
self.log.info(f"Message received ({protocol}) {args} {kwargs}")
|
||||||
|
identifier = kwargs.get("identifier")
|
||||||
|
local_message = kwargs.get("local_message")
|
||||||
|
message_text = str(kwargs.get("text") or "").strip()
|
||||||
|
if local_message is None:
|
||||||
|
return
|
||||||
|
channel_identifier = ""
|
||||||
|
if isinstance(identifier, PersonIdentifier):
|
||||||
|
channel_identifier = str(identifier.identifier or "").strip()
|
||||||
|
elif identifier is not None:
|
||||||
|
channel_identifier = str(identifier or "").strip()
|
||||||
|
if channel_identifier:
|
||||||
|
try:
|
||||||
|
await process_inbound_message(
|
||||||
|
CommandContext(
|
||||||
|
service=str(protocol or "").strip().lower(),
|
||||||
|
channel_identifier=channel_identifier,
|
||||||
|
message_id=str(local_message.id),
|
||||||
|
user_id=int(local_message.user_id),
|
||||||
|
message_text=message_text,
|
||||||
|
payload=dict(kwargs.get("payload") or {}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.warning("Command engine processing failed: %s", exc)
|
||||||
|
try:
|
||||||
|
await process_inbound_translation(local_message)
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.warning("Translation engine processing failed: %s", exc)
|
||||||
|
|
||||||
async def _resolve_identifier_objects(self, protocol, identifier):
|
async def _resolve_identifier_objects(self, protocol, identifier):
|
||||||
if isinstance(identifier, PersonIdentifier):
|
if isinstance(identifier, PersonIdentifier):
|
||||||
|
|||||||
@@ -392,6 +392,9 @@
|
|||||||
<a class="navbar-item" href="{% url 'ais' type='page' %}">
|
<a class="navbar-item" href="{% url 'ais' type='page' %}">
|
||||||
AI
|
AI
|
||||||
</a>
|
</a>
|
||||||
|
<a class="navbar-item" href="{% url 'command_routing' %}">
|
||||||
|
Command Routing
|
||||||
|
</a>
|
||||||
{% if user.is_superuser %}
|
{% if user.is_superuser %}
|
||||||
<a class="navbar-item" href="{% url 'system_settings' %}">
|
<a class="navbar-item" href="{% url 'system_settings' %}">
|
||||||
System
|
System
|
||||||
|
|||||||
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 class="icon is-small"><i class="fa-solid fa-rotate-right"></i></span>
|
||||||
<span>Force Sync</span>
|
<span>Force Sync</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div class="compose-command-menu">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-light is-rounded compose-command-menu-toggle"
|
||||||
|
title="Enable or disable command triggers for this chat">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-diagram-project"></i></span>
|
||||||
|
<span>Commands</span>
|
||||||
|
</button>
|
||||||
|
<div class="compose-command-menu-panel is-hidden">
|
||||||
|
{% for option in command_options %}
|
||||||
|
<label class="compose-command-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="compose-command-toggle"
|
||||||
|
data-command-slug="{{ option.slug }}"
|
||||||
|
{% if option.enabled_here %}checked{% endif %}>
|
||||||
|
<span class="compose-command-option-title">{{ option.name }}</span>
|
||||||
|
{% if option.trigger_token %}
|
||||||
|
<span class="compose-command-option-token">{{ option.trigger_token }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
<a class="compose-command-settings-link" href="{% url 'command_routing' %}">Open command routing</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="drafts">
|
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="drafts">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
|
||||||
<span>Drafts</span>
|
<span>Drafts</span>
|
||||||
@@ -241,10 +266,11 @@
|
|||||||
data-summary-url="{{ compose_summary_url }}"
|
data-summary-url="{{ compose_summary_url }}"
|
||||||
data-quick-insights-url="{{ compose_quick_insights_url }}"
|
data-quick-insights-url="{{ compose_quick_insights_url }}"
|
||||||
data-history-sync-url="{{ compose_history_sync_url }}"
|
data-history-sync-url="{{ compose_history_sync_url }}"
|
||||||
|
data-toggle-command-url="{{ compose_toggle_command_url }}"
|
||||||
data-engage-preview-url="{{ compose_engage_preview_url }}"
|
data-engage-preview-url="{{ compose_engage_preview_url }}"
|
||||||
data-engage-send-url="{{ compose_engage_send_url }}">
|
data-engage-send-url="{{ compose_engage_send_url }}">
|
||||||
{% for msg in serialized_messages %}
|
{% for msg in serialized_messages %}
|
||||||
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}" data-message-id="{{ msg.id }}">
|
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}" data-message-id="{{ msg.id }}"{% if msg.reply_to_id %} data-reply-to-id="{{ msg.reply_to_id }}"{% endif %} data-reply-snippet="{{ msg.display_text|default:msg.text|default:''|truncatechars:120|escape }}">
|
||||||
{% if msg.gap_fragments %}
|
{% if msg.gap_fragments %}
|
||||||
{% with gap=msg.gap_fragments.0 %}
|
{% with gap=msg.gap_fragments.0 %}
|
||||||
<p
|
<p
|
||||||
@@ -256,6 +282,11 @@
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
|
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
|
||||||
|
{% if msg.reply_to_id %}
|
||||||
|
<div class="compose-reply-ref" data-reply-target-id="{{ msg.reply_to_id }}" data-reply-preview="{{ msg.reply_preview|default:''|escape }}">
|
||||||
|
<button type="button" class="compose-reply-link" title="Jump to referenced message"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="compose-source-badge-wrap">
|
<div class="compose-source-badge-wrap">
|
||||||
<span class="compose-source-badge source-{{ msg.source_service|default:'web'|lower }}">{{ msg.source_label }}</span>
|
<span class="compose-source-badge source-{{ msg.source_service|default:'web'|lower }}">{{ msg.source_label }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -336,6 +367,10 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
<button type="button" class="compose-reply-btn" title="Reply to this message" aria-label="Reply to this message">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-reply"></i></span>
|
||||||
|
<span class="compose-reply-btn-label">Reply</span>
|
||||||
|
</button>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
@@ -365,6 +400,7 @@
|
|||||||
<input type="hidden" name="render_mode" value="{{ render_mode }}">
|
<input type="hidden" name="render_mode" value="{{ render_mode }}">
|
||||||
<input type="hidden" name="limit" value="{{ limit }}">
|
<input type="hidden" name="limit" value="{{ limit }}">
|
||||||
<input type="hidden" name="panel_id" value="{{ panel_id }}">
|
<input type="hidden" name="panel_id" value="{{ panel_id }}">
|
||||||
|
<input type="hidden" name="reply_to_message_id" value="">
|
||||||
<input type="hidden" name="failsafe_arm" value="0">
|
<input type="hidden" name="failsafe_arm" value="0">
|
||||||
<input type="hidden" name="failsafe_confirm" value="0">
|
<input type="hidden" name="failsafe_confirm" value="0">
|
||||||
<div class="compose-send-safety">
|
<div class="compose-send-safety">
|
||||||
@@ -372,6 +408,11 @@
|
|||||||
<input type="checkbox" class="manual-confirm"> Confirm Send
|
<input type="checkbox" class="manual-confirm"> Confirm Send
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="{{ panel_id }}-reply-banner" class="compose-reply-banner is-hidden">
|
||||||
|
<span class="compose-reply-banner-label">Replying to:</span>
|
||||||
|
<span id="{{ panel_id }}-reply-text" class="compose-reply-banner-text"></span>
|
||||||
|
<button type="button" id="{{ panel_id }}-reply-clear" class="button is-white is-small compose-reply-clear-btn">Clear</button>
|
||||||
|
</div>
|
||||||
<div class="compose-composer-capsule">
|
<div class="compose-composer-capsule">
|
||||||
<textarea
|
<textarea
|
||||||
id="{{ panel_id }}-textarea"
|
id="{{ panel_id }}-textarea"
|
||||||
@@ -414,6 +455,13 @@
|
|||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .compose-row.compose-reply-target {
|
||||||
|
animation: composeReplyFlash 1.1s ease-out;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-row.compose-reply-selected .compose-bubble {
|
||||||
|
border-color: rgba(47, 79, 122, 0.6);
|
||||||
|
box-shadow: 0 0 0 2px rgba(47, 79, 122, 0.12);
|
||||||
|
}
|
||||||
#{{ panel_id }} .compose-row.is-in {
|
#{{ panel_id }} .compose-row.is-in {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
@@ -478,6 +526,56 @@
|
|||||||
padding: 0.52rem 0.62rem;
|
padding: 0.52rem 0.62rem;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .compose-reply-ref {
|
||||||
|
margin-bottom: 0.28rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-reply-link {
|
||||||
|
border: 0;
|
||||||
|
background: rgba(31, 41, 55, 0.06);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.12rem 0.42rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #3b4b5e;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-reply-link:hover {
|
||||||
|
background: rgba(31, 41, 55, 0.1);
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-reply-btn {
|
||||||
|
margin-top: 0.34rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #5f6f82;
|
||||||
|
padding: 0.1rem 0.16rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
min-height: 1.4rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-reply-btn-label {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-row:hover .compose-reply-btn,
|
||||||
|
#{{ panel_id }} .compose-row.compose-reply-selected .compose-reply-btn,
|
||||||
|
#{{ panel_id }} .compose-reply-btn:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-reply-btn:hover {
|
||||||
|
color: #2f4f7a;
|
||||||
|
}
|
||||||
#{{ panel_id }} .compose-source-badge-wrap {
|
#{{ panel_id }} .compose-source-badge-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
@@ -667,6 +765,47 @@
|
|||||||
#{{ panel_id }} .compose-platform-select {
|
#{{ panel_id }} .compose-platform-select {
|
||||||
min-width: 11rem;
|
min-width: 11rem;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .compose-command-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-command-menu-panel {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 0.3rem);
|
||||||
|
min-width: 14.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.14);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 9;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-command-menu-panel.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-command-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-command-option-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-command-option-token {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #64748b;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-command-settings-link {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
#{{ panel_id }} .compose-gap-artifacts {
|
#{{ panel_id }} .compose-gap-artifacts {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
width: min(92%, 34rem);
|
width: min(92%, 34rem);
|
||||||
@@ -800,6 +939,38 @@
|
|||||||
margin-bottom: 0.45rem;
|
margin-bottom: 0.45rem;
|
||||||
color: #505050;
|
color: #505050;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .compose-reply-banner {
|
||||||
|
margin-top: 0.42rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
border: 1px solid rgba(47, 79, 122, 0.24);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(238, 246, 255, 0.7);
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-reply-banner.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-reply-banner-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #34506f;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-reply-banner-text {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #213447;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-reply-clear-btn {
|
||||||
|
border: 1px solid rgba(47, 79, 122, 0.25);
|
||||||
|
color: #2f4f7a;
|
||||||
|
}
|
||||||
#{{ panel_id }} .compose-status {
|
#{{ panel_id }} .compose-status {
|
||||||
margin-top: 0.55rem;
|
margin-top: 0.55rem;
|
||||||
min-height: 1.1rem;
|
min-height: 1.1rem;
|
||||||
@@ -1252,6 +1423,10 @@
|
|||||||
50% { transform: translateX(2px); }
|
50% { transform: translateX(2px); }
|
||||||
75% { transform: translateX(-1px); }
|
75% { transform: translateX(-1px); }
|
||||||
}
|
}
|
||||||
|
@keyframes composeReplyFlash {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(47, 79, 122, 0.45); }
|
||||||
|
100% { box-shadow: 0 0 0 14px rgba(47, 79, 122, 0); }
|
||||||
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
#{{ panel_id }} .compose-thread {
|
#{{ panel_id }} .compose-thread {
|
||||||
max-height: 52vh;
|
max-height: 52vh;
|
||||||
@@ -1294,6 +1469,10 @@
|
|||||||
const hiddenService = document.getElementById(panelId + "-input-service");
|
const hiddenService = document.getElementById(panelId + "-input-service");
|
||||||
const hiddenIdentifier = document.getElementById(panelId + "-input-identifier");
|
const hiddenIdentifier = document.getElementById(panelId + "-input-identifier");
|
||||||
const hiddenPerson = document.getElementById(panelId + "-input-person");
|
const hiddenPerson = document.getElementById(panelId + "-input-person");
|
||||||
|
const hiddenReplyTo = form.querySelector('input[name="reply_to_message_id"]');
|
||||||
|
const replyBanner = document.getElementById(panelId + "-reply-banner");
|
||||||
|
const replyBannerText = document.getElementById(panelId + "-reply-text");
|
||||||
|
const replyClearBtn = document.getElementById(panelId + "-reply-clear");
|
||||||
const renderMode = "{{ render_mode }}";
|
const renderMode = "{{ render_mode }}";
|
||||||
if (!thread || !form || !textarea) {
|
if (!thread || !form || !textarea) {
|
||||||
return;
|
return;
|
||||||
@@ -1348,6 +1527,7 @@
|
|||||||
lightboxIndex: -1,
|
lightboxIndex: -1,
|
||||||
seenMessageIds: new Set(),
|
seenMessageIds: new Set(),
|
||||||
replyTimingTimer: null,
|
replyTimingTimer: null,
|
||||||
|
replyTargetId: "",
|
||||||
};
|
};
|
||||||
window.giaComposePanels[panelId] = panelState;
|
window.giaComposePanels[panelId] = panelState;
|
||||||
const triggerButtons = Array.from(panel.querySelectorAll(".js-ai-trigger"));
|
const triggerButtons = Array.from(panel.querySelectorAll(".js-ai-trigger"));
|
||||||
@@ -1681,6 +1861,80 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const bindCommandMenu = function (rootNode) {
|
||||||
|
const scope = rootNode || panel;
|
||||||
|
if (!scope) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scope.querySelectorAll(".compose-command-menu").forEach(function (menu) {
|
||||||
|
if (menu.dataset.bound === "1") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
menu.dataset.bound = "1";
|
||||||
|
const toggleButton = menu.querySelector(".compose-command-menu-toggle");
|
||||||
|
const menuPanel = menu.querySelector(".compose-command-menu-panel");
|
||||||
|
if (!toggleButton || !menuPanel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const closeMenu = function () {
|
||||||
|
menuPanel.classList.add("is-hidden");
|
||||||
|
};
|
||||||
|
const openMenu = function () {
|
||||||
|
menuPanel.classList.remove("is-hidden");
|
||||||
|
};
|
||||||
|
toggleButton.addEventListener("click", function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (menuPanel.classList.contains("is-hidden")) {
|
||||||
|
openMenu();
|
||||||
|
} else {
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener("click", function (ev) {
|
||||||
|
if (!menu.contains(ev.target)) {
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
menu.querySelectorAll(".compose-command-toggle").forEach(function (checkbox) {
|
||||||
|
checkbox.addEventListener("change", async function () {
|
||||||
|
const toggleUrl = String(thread.dataset.toggleCommandUrl || "").trim();
|
||||||
|
const slug = String(checkbox.dataset.commandSlug || "").trim();
|
||||||
|
if (!toggleUrl || !slug) {
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
setStatus("Command toggle endpoint is unavailable.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const shouldEnable = !!checkbox.checked;
|
||||||
|
checkbox.disabled = true;
|
||||||
|
try {
|
||||||
|
const params = queryParams({
|
||||||
|
slug: slug,
|
||||||
|
enabled: shouldEnable ? "1" : "0",
|
||||||
|
});
|
||||||
|
const result = await postFormJson(toggleUrl, params);
|
||||||
|
if (!result.ok) {
|
||||||
|
checkbox.checked = !shouldEnable;
|
||||||
|
setStatus(
|
||||||
|
String(result.message || result.error || "Command update failed."),
|
||||||
|
String(result.level || "danger")
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(
|
||||||
|
String(result.message || (slug + (shouldEnable ? " enabled." : " disabled."))),
|
||||||
|
"success"
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
checkbox.checked = !shouldEnable;
|
||||||
|
setStatus("Failed to update command binding.", "danger");
|
||||||
|
} finally {
|
||||||
|
checkbox.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const ensureEmptyState = function (messageText) {
|
const ensureEmptyState = function (messageText) {
|
||||||
if (!thread) {
|
if (!thread) {
|
||||||
@@ -2060,6 +2314,12 @@
|
|||||||
row.className = "compose-row " + (outgoing ? "is-out" : "is-in");
|
row.className = "compose-row " + (outgoing ? "is-out" : "is-in");
|
||||||
row.dataset.ts = String(msg.ts || 0);
|
row.dataset.ts = String(msg.ts || 0);
|
||||||
row.dataset.minute = minuteBucketFromTs(msg.ts || 0);
|
row.dataset.minute = minuteBucketFromTs(msg.ts || 0);
|
||||||
|
row.dataset.replySnippet = normalizeSnippet(
|
||||||
|
msg.display_text || msg.text || (msg.image_url ? "" : "(no text)")
|
||||||
|
);
|
||||||
|
if (msg.reply_to_id) {
|
||||||
|
row.dataset.replyToId = String(msg.reply_to_id || "");
|
||||||
|
}
|
||||||
if (messageId) {
|
if (messageId) {
|
||||||
row.dataset.messageId = messageId;
|
row.dataset.messageId = messageId;
|
||||||
panelState.seenMessageIds.add(messageId);
|
panelState.seenMessageIds.add(messageId);
|
||||||
@@ -2069,6 +2329,19 @@
|
|||||||
const bubble = document.createElement("article");
|
const bubble = document.createElement("article");
|
||||||
bubble.className = "compose-bubble " + (outgoing ? "is-out" : "is-in");
|
bubble.className = "compose-bubble " + (outgoing ? "is-out" : "is-in");
|
||||||
|
|
||||||
|
if (msg.reply_to_id) {
|
||||||
|
const replyRef = document.createElement("div");
|
||||||
|
replyRef.className = "compose-reply-ref";
|
||||||
|
replyRef.dataset.replyTargetId = String(msg.reply_to_id || "");
|
||||||
|
replyRef.dataset.replyPreview = String(msg.reply_preview || "");
|
||||||
|
const link = document.createElement("button");
|
||||||
|
link.type = "button";
|
||||||
|
link.className = "compose-reply-link";
|
||||||
|
link.title = "Jump to referenced message";
|
||||||
|
replyRef.appendChild(link);
|
||||||
|
bubble.appendChild(replyRef);
|
||||||
|
}
|
||||||
|
|
||||||
// Add source badge for client-side rendered messages
|
// Add source badge for client-side rendered messages
|
||||||
if (msg.source_label) {
|
if (msg.source_label) {
|
||||||
const badgeWrap = document.createElement("div");
|
const badgeWrap = document.createElement("div");
|
||||||
@@ -2178,6 +2451,14 @@
|
|||||||
meta.appendChild(tickWrap);
|
meta.appendChild(tickWrap);
|
||||||
}
|
}
|
||||||
bubble.appendChild(meta);
|
bubble.appendChild(meta);
|
||||||
|
const replyBtn = document.createElement("button");
|
||||||
|
replyBtn.type = "button";
|
||||||
|
replyBtn.className = "compose-reply-btn";
|
||||||
|
replyBtn.title = "Reply to this message";
|
||||||
|
replyBtn.setAttribute("aria-label", "Reply to this message");
|
||||||
|
replyBtn.innerHTML =
|
||||||
|
'<span class="icon is-small"><i class="fa-solid fa-reply"></i></span><span class="compose-reply-btn-label">Reply</span>';
|
||||||
|
bubble.appendChild(replyBtn);
|
||||||
|
|
||||||
// If message carries receipt metadata, append dataset so the popover can use it.
|
// If message carries receipt metadata, append dataset so the popover can use it.
|
||||||
if (msg.receipt_payload || msg.read_source_service || msg.read_by_identifier) {
|
if (msg.receipt_payload || msg.read_source_service || msg.read_by_identifier) {
|
||||||
@@ -2202,6 +2483,7 @@
|
|||||||
row.appendChild(bubble);
|
row.appendChild(bubble);
|
||||||
insertRowByTs(row);
|
insertRowByTs(row);
|
||||||
wireImageFallbacks(row);
|
wireImageFallbacks(row);
|
||||||
|
bindReplyReferences(row);
|
||||||
updateGlanceFromMessage(msg);
|
updateGlanceFromMessage(msg);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2261,6 +2543,19 @@
|
|||||||
|
|
||||||
// Delegate click on tick triggers inside thread
|
// Delegate click on tick triggers inside thread
|
||||||
thread.addEventListener("click", function (ev) {
|
thread.addEventListener("click", function (ev) {
|
||||||
|
const replyBtn = ev.target.closest && ev.target.closest(".compose-reply-btn");
|
||||||
|
if (replyBtn) {
|
||||||
|
const row = replyBtn.closest(".compose-row");
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetId = String(row.dataset.messageId || "").trim();
|
||||||
|
setReplyTarget(targetId, row.dataset.replySnippet || "");
|
||||||
|
if (textarea) {
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const btn = ev.target.closest && ev.target.closest('.js-receipt-trigger');
|
const btn = ev.target.closest && ev.target.closest('.js-receipt-trigger');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
if (activeReceiptBtn === btn && !receiptPopover.classList.contains('is-hidden')) {
|
if (activeReceiptBtn === btn && !receiptPopover.classList.contains('is-hidden')) {
|
||||||
@@ -2456,6 +2751,7 @@
|
|||||||
}
|
}
|
||||||
applyMinuteGrouping();
|
applyMinuteGrouping();
|
||||||
bindHistorySyncButtons(panel);
|
bindHistorySyncButtons(panel);
|
||||||
|
bindCommandMenu(panel);
|
||||||
|
|
||||||
const setStatus = function (message, level) {
|
const setStatus = function (message, level) {
|
||||||
if (!statusBox) {
|
if (!statusBox) {
|
||||||
@@ -2551,6 +2847,135 @@
|
|||||||
return params;
|
return params;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeSnippet = function (value) {
|
||||||
|
const compact = String(value || "").replace(/\s+/g, " ").trim();
|
||||||
|
if (!compact) {
|
||||||
|
return "(no text)";
|
||||||
|
}
|
||||||
|
if (compact.length <= 120) {
|
||||||
|
return compact;
|
||||||
|
}
|
||||||
|
return compact.slice(0, 117).trimEnd() + "...";
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowByMessageId = function (messageId) {
|
||||||
|
const key = String(messageId || "").trim();
|
||||||
|
if (!key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return thread.querySelector('.compose-row[data-message-id="' + key + '"]');
|
||||||
|
};
|
||||||
|
|
||||||
|
const flashReplyTarget = function (row) {
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.classList.remove("compose-reply-target");
|
||||||
|
void row.offsetWidth;
|
||||||
|
row.classList.add("compose-reply-target");
|
||||||
|
window.setTimeout(function () {
|
||||||
|
row.classList.remove("compose-reply-target");
|
||||||
|
}, 1200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearReplySelectionClass = function () {
|
||||||
|
thread.querySelectorAll(".compose-row.compose-reply-selected").forEach(function (row) {
|
||||||
|
row.classList.remove("compose-reply-selected");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearReplyTarget = function () {
|
||||||
|
panelState.replyTargetId = "";
|
||||||
|
if (hiddenReplyTo) {
|
||||||
|
hiddenReplyTo.value = "";
|
||||||
|
}
|
||||||
|
if (replyBanner) {
|
||||||
|
replyBanner.classList.add("is-hidden");
|
||||||
|
}
|
||||||
|
if (replyBannerText) {
|
||||||
|
replyBannerText.textContent = "";
|
||||||
|
}
|
||||||
|
clearReplySelectionClass();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setReplyTarget = function (messageId, explicitPreview) {
|
||||||
|
const key = String(messageId || "").trim();
|
||||||
|
if (!key) {
|
||||||
|
clearReplyTarget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = rowByMessageId(key);
|
||||||
|
let preview = normalizeSnippet(explicitPreview || "");
|
||||||
|
if (row) {
|
||||||
|
const rowSnippet = normalizeSnippet(row.dataset.replySnippet || "");
|
||||||
|
if (rowSnippet && rowSnippet !== "(no text)") {
|
||||||
|
preview = rowSnippet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panelState.replyTargetId = key;
|
||||||
|
if (hiddenReplyTo) {
|
||||||
|
hiddenReplyTo.value = key;
|
||||||
|
}
|
||||||
|
if (replyBannerText) {
|
||||||
|
replyBannerText.textContent = preview;
|
||||||
|
}
|
||||||
|
if (replyBanner) {
|
||||||
|
replyBanner.classList.remove("is-hidden");
|
||||||
|
}
|
||||||
|
clearReplySelectionClass();
|
||||||
|
if (row) {
|
||||||
|
row.classList.add("compose-reply-selected");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindReplyReferences = function (rootNode) {
|
||||||
|
const scope = rootNode || thread;
|
||||||
|
if (!scope) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scope.querySelectorAll(".compose-row").forEach(function (row) {
|
||||||
|
if (!row.dataset.replySnippet) {
|
||||||
|
const body = row.querySelector(".compose-body");
|
||||||
|
if (body) {
|
||||||
|
row.dataset.replySnippet = normalizeSnippet(body.textContent || "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
scope.querySelectorAll(".compose-reply-ref").forEach(function (ref) {
|
||||||
|
const button = ref.querySelector(".compose-reply-link");
|
||||||
|
const targetId = String(ref.dataset.replyTargetId || "").trim();
|
||||||
|
if (!button || !targetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetRow = rowByMessageId(targetId);
|
||||||
|
const inferredPreview = targetRow
|
||||||
|
? normalizeSnippet(targetRow.dataset.replySnippet || "")
|
||||||
|
: normalizeSnippet(ref.dataset.replyPreview || "");
|
||||||
|
button.textContent = "Reply to: " + inferredPreview;
|
||||||
|
if (button.dataset.bound === "1") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
button.dataset.bound = "1";
|
||||||
|
button.addEventListener("click", function () {
|
||||||
|
const row = rowByMessageId(targetId);
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
flashReplyTarget(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
bindReplyReferences(panel);
|
||||||
|
if (replyClearBtn) {
|
||||||
|
replyClearBtn.addEventListener("click", function () {
|
||||||
|
clearReplyTarget();
|
||||||
|
if (textarea) {
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const postFormJson = async function (url, params) {
|
const postFormJson = async function (url, params) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -2619,6 +3044,7 @@
|
|||||||
if (metaLine) {
|
if (metaLine) {
|
||||||
metaLine.textContent = titleCase(service) + " · " + identifier;
|
metaLine.textContent = titleCase(service) + " · " + identifier;
|
||||||
}
|
}
|
||||||
|
clearReplyTarget();
|
||||||
if (panelState.socket) {
|
if (panelState.socket) {
|
||||||
try {
|
try {
|
||||||
panelState.socket.close();
|
panelState.socket.close();
|
||||||
@@ -3468,6 +3894,7 @@
|
|||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
setStatus('', 'success');
|
setStatus('', 'success');
|
||||||
textarea.value = '';
|
textarea.value = '';
|
||||||
|
clearReplyTarget();
|
||||||
autosize();
|
autosize();
|
||||||
flashCompose('is-send-success');
|
flashCompose('is-send-success');
|
||||||
poll(true);
|
poll(true);
|
||||||
@@ -3552,6 +3979,7 @@
|
|||||||
flashCompose("is-send-success");
|
flashCompose("is-send-success");
|
||||||
setStatus("", "success");
|
setStatus("", "success");
|
||||||
textarea.value = "";
|
textarea.value = "";
|
||||||
|
clearReplyTarget();
|
||||||
autosize();
|
autosize();
|
||||||
poll(true);
|
poll(true);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
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 django.views import View
|
||||||
|
|
||||||
from core.clients import transport
|
from core.clients import transport
|
||||||
|
from core.commands.base import CommandContext
|
||||||
|
from core.commands.engine import process_inbound_message
|
||||||
from core.messaging import ai as ai_runner
|
from core.messaging import ai as ai_runner
|
||||||
from core.messaging import media_bridge
|
from core.messaging import media_bridge
|
||||||
from core.messaging.utils import messages_to_string
|
from core.messaging.utils import messages_to_string
|
||||||
@@ -33,6 +35,9 @@ from core.models import (
|
|||||||
AI,
|
AI,
|
||||||
Chat,
|
Chat,
|
||||||
ChatSession,
|
ChatSession,
|
||||||
|
CommandAction,
|
||||||
|
CommandChannelBinding,
|
||||||
|
CommandProfile,
|
||||||
Message,
|
Message,
|
||||||
MessageEvent,
|
MessageEvent,
|
||||||
PatternMitigationPlan,
|
PatternMitigationPlan,
|
||||||
@@ -42,6 +47,7 @@ from core.models import (
|
|||||||
WorkspaceConversation,
|
WorkspaceConversation,
|
||||||
)
|
)
|
||||||
from core.realtime.typing_state import get_person_typing_state
|
from core.realtime.typing_state import get_person_typing_state
|
||||||
|
from core.translation.engine import process_inbound_translation
|
||||||
from core.views.workspace import (
|
from core.views.workspace import (
|
||||||
INSIGHT_METRICS,
|
INSIGHT_METRICS,
|
||||||
_build_engage_payload,
|
_build_engage_payload,
|
||||||
@@ -466,6 +472,18 @@ def _serialize_message(msg: Message) -> dict:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
reply_preview = ""
|
||||||
|
try:
|
||||||
|
reply_obj = getattr(msg, "reply_to", None)
|
||||||
|
if reply_obj is not None:
|
||||||
|
reply_preview = str(getattr(reply_obj, "text", "") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
reply_preview = ""
|
||||||
|
if reply_preview:
|
||||||
|
reply_preview = re.sub(r"\s+", " ", reply_preview).strip()
|
||||||
|
if len(reply_preview) > 140:
|
||||||
|
reply_preview = reply_preview[:137].rstrip() + "..."
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": str(msg.id),
|
"id": str(msg.id),
|
||||||
"ts": int(msg.ts or 0),
|
"ts": int(msg.ts or 0),
|
||||||
@@ -491,6 +509,15 @@ def _serialize_message(msg: Message) -> dict:
|
|||||||
"read_source_service": read_source_service,
|
"read_source_service": read_source_service,
|
||||||
"read_by_identifier": read_by_identifier,
|
"read_by_identifier": read_by_identifier,
|
||||||
"reactions": reaction_rows,
|
"reactions": reaction_rows,
|
||||||
|
"source_message_id": str(getattr(msg, "source_message_id", "") or ""),
|
||||||
|
"reply_to_id": str(getattr(msg, "reply_to_id", "") or ""),
|
||||||
|
"reply_source_message_id": str(
|
||||||
|
getattr(msg, "reply_source_message_id", "") or ""
|
||||||
|
),
|
||||||
|
"reply_preview": reply_preview,
|
||||||
|
"message_meta": {
|
||||||
|
"origin_tag": str((getattr(msg, "message_meta", {}) or {}).get("origin_tag") or "")
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1498,6 +1525,16 @@ def _context_base(user, service, identifier, person):
|
|||||||
service = person_identifier.service
|
service = person_identifier.service
|
||||||
identifier = person_identifier.identifier
|
identifier = person_identifier.identifier
|
||||||
person = person_identifier.person
|
person = person_identifier.person
|
||||||
|
if service == "whatsapp" and identifier and "@" not in str(identifier):
|
||||||
|
bare_id = str(identifier).split("@", 1)[0].strip()
|
||||||
|
group_link = PlatformChatLink.objects.filter(
|
||||||
|
user=user,
|
||||||
|
service="whatsapp",
|
||||||
|
chat_identifier=bare_id,
|
||||||
|
is_group=True,
|
||||||
|
).first()
|
||||||
|
if group_link is not None:
|
||||||
|
identifier = str(group_link.chat_jid or f"{bare_id}@g.us")
|
||||||
|
|
||||||
if person_identifier is None and identifier:
|
if person_identifier is None and identifier:
|
||||||
bare_id = identifier.split("@", 1)[0].strip()
|
bare_id = identifier.split("@", 1)[0].strip()
|
||||||
@@ -1527,6 +1564,208 @@ def _context_base(user, service, identifier, person):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_whatsapp_bridge_ref(message: Message | None) -> dict:
|
||||||
|
if message is None:
|
||||||
|
return {}
|
||||||
|
payload = dict(getattr(message, "receipt_payload", {}) or {})
|
||||||
|
refs = dict(payload.get("bridge_refs") or {})
|
||||||
|
rows = list(refs.get("whatsapp") or [])
|
||||||
|
best = {}
|
||||||
|
best_updated = -1
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
upstream_id = str(row.get("upstream_message_id") or "").strip()
|
||||||
|
if not upstream_id:
|
||||||
|
continue
|
||||||
|
updated_at = int(row.get("updated_at") or 0)
|
||||||
|
if updated_at >= best_updated:
|
||||||
|
best = dict(row)
|
||||||
|
best_updated = updated_at
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _build_whatsapp_reply_metadata(reply_to: Message | None, channel_identifier: str) -> dict:
|
||||||
|
if reply_to is None:
|
||||||
|
return {}
|
||||||
|
target_message_id = ""
|
||||||
|
participant = ""
|
||||||
|
|
||||||
|
source_service = str(getattr(reply_to, "source_service", "") or "").strip().lower()
|
||||||
|
if source_service == "whatsapp":
|
||||||
|
target_message_id = str(getattr(reply_to, "source_message_id", "") or "").strip()
|
||||||
|
participant = str(getattr(reply_to, "sender_uuid", "") or "").strip()
|
||||||
|
|
||||||
|
if not target_message_id:
|
||||||
|
bridge_ref = _latest_whatsapp_bridge_ref(reply_to)
|
||||||
|
target_message_id = str(bridge_ref.get("upstream_message_id") or "").strip()
|
||||||
|
participant = participant or str(bridge_ref.get("upstream_author") or "").strip()
|
||||||
|
|
||||||
|
if not target_message_id:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
remote_jid = str(channel_identifier or "").strip()
|
||||||
|
if "@" not in remote_jid and remote_jid:
|
||||||
|
remote_jid = f"{remote_jid}@g.us"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"reply_to_upstream_message_id": target_message_id,
|
||||||
|
"reply_to_participant": participant,
|
||||||
|
"reply_to_remote_jid": remote_jid,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_command_channel_identifier(service: str, identifier: str) -> str:
|
||||||
|
value = str(identifier or "").strip()
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
if service == "whatsapp":
|
||||||
|
return value.split("@", 1)[0].strip()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _command_channel_identifier_variants(service: str, identifier: str) -> set[str]:
|
||||||
|
canonical = _canonical_command_channel_identifier(service, identifier)
|
||||||
|
if not canonical:
|
||||||
|
return set()
|
||||||
|
variants = {canonical}
|
||||||
|
if service == "whatsapp":
|
||||||
|
variants.add(f"{canonical}@g.us")
|
||||||
|
return variants
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_bp_profile_and_actions(user) -> CommandProfile:
|
||||||
|
profile, _ = CommandProfile.objects.get_or_create(
|
||||||
|
user=user,
|
||||||
|
slug="bp",
|
||||||
|
defaults={
|
||||||
|
"name": "Business Plan",
|
||||||
|
"enabled": True,
|
||||||
|
"trigger_token": "#bp#",
|
||||||
|
"reply_required": True,
|
||||||
|
"exact_match_only": True,
|
||||||
|
"window_scope": "conversation",
|
||||||
|
"visibility_mode": "status_in_source",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not profile.enabled:
|
||||||
|
profile.enabled = True
|
||||||
|
profile.save(update_fields=["enabled", "updated_at"])
|
||||||
|
for action_type, position in (
|
||||||
|
("extract_bp", 0),
|
||||||
|
("save_document", 1),
|
||||||
|
("post_result", 2),
|
||||||
|
):
|
||||||
|
row, created = CommandAction.objects.get_or_create(
|
||||||
|
profile=profile,
|
||||||
|
action_type=action_type,
|
||||||
|
defaults={"enabled": True, "position": position},
|
||||||
|
)
|
||||||
|
if (not created) and (not row.enabled):
|
||||||
|
row.enabled = True
|
||||||
|
row.save(update_fields=["enabled", "updated_at"])
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def _toggle_command_for_channel(
|
||||||
|
*,
|
||||||
|
user,
|
||||||
|
service: str,
|
||||||
|
identifier: str,
|
||||||
|
slug: str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
service_key = _default_service(service)
|
||||||
|
canonical_identifier = _canonical_command_channel_identifier(service_key, identifier)
|
||||||
|
if not canonical_identifier:
|
||||||
|
return (False, "missing_identifier")
|
||||||
|
|
||||||
|
if slug == "bp":
|
||||||
|
profile = _ensure_bp_profile_and_actions(user)
|
||||||
|
else:
|
||||||
|
profile = (
|
||||||
|
CommandProfile.objects.filter(user=user, slug=slug)
|
||||||
|
.order_by("id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if profile is None:
|
||||||
|
return (False, f"unknown_command:{slug}")
|
||||||
|
if not profile.enabled and enabled:
|
||||||
|
profile.enabled = True
|
||||||
|
profile.save(update_fields=["enabled", "updated_at"])
|
||||||
|
|
||||||
|
variants = _command_channel_identifier_variants(service_key, canonical_identifier)
|
||||||
|
if not variants:
|
||||||
|
return (False, "missing_identifier")
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
for direction in ("ingress", "egress"):
|
||||||
|
row, _ = CommandChannelBinding.objects.get_or_create(
|
||||||
|
profile=profile,
|
||||||
|
direction=direction,
|
||||||
|
service=service_key,
|
||||||
|
channel_identifier=canonical_identifier,
|
||||||
|
defaults={"enabled": True},
|
||||||
|
)
|
||||||
|
if not row.enabled:
|
||||||
|
row.enabled = True
|
||||||
|
row.save(update_fields=["enabled", "updated_at"])
|
||||||
|
CommandChannelBinding.objects.filter(
|
||||||
|
profile=profile,
|
||||||
|
direction=direction,
|
||||||
|
service=service_key,
|
||||||
|
channel_identifier__in=list(variants - {canonical_identifier}),
|
||||||
|
).update(enabled=False)
|
||||||
|
else:
|
||||||
|
CommandChannelBinding.objects.filter(
|
||||||
|
profile=profile,
|
||||||
|
direction__in=("ingress", "egress"),
|
||||||
|
service=service_key,
|
||||||
|
channel_identifier__in=list(variants),
|
||||||
|
).update(enabled=False)
|
||||||
|
return (True, "")
|
||||||
|
|
||||||
|
|
||||||
|
def _command_options_for_channel(user, service: str, identifier: str) -> list[dict]:
|
||||||
|
service_key = _default_service(service)
|
||||||
|
variants = _command_channel_identifier_variants(service_key, identifier)
|
||||||
|
profiles = list(
|
||||||
|
CommandProfile.objects.filter(user=user).order_by("slug", "id")
|
||||||
|
)
|
||||||
|
by_slug = {str(row.slug or "").strip(): row for row in profiles}
|
||||||
|
if "bp" not in by_slug:
|
||||||
|
by_slug["bp"] = CommandProfile(
|
||||||
|
user=user,
|
||||||
|
slug="bp",
|
||||||
|
name="Business Plan",
|
||||||
|
trigger_token="#bp#",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
slugs = sorted(by_slug.keys())
|
||||||
|
options = []
|
||||||
|
for slug in slugs:
|
||||||
|
profile = by_slug[slug]
|
||||||
|
enabled_here = False
|
||||||
|
if variants:
|
||||||
|
enabled_here = CommandChannelBinding.objects.filter(
|
||||||
|
profile_id=profile.id if profile.id else None,
|
||||||
|
direction="ingress",
|
||||||
|
service=service_key,
|
||||||
|
channel_identifier__in=list(variants),
|
||||||
|
enabled=True,
|
||||||
|
).exists()
|
||||||
|
options.append(
|
||||||
|
{
|
||||||
|
"slug": slug,
|
||||||
|
"name": str(profile.name or slug).strip() or slug,
|
||||||
|
"trigger_token": str(profile.trigger_token or "").strip(),
|
||||||
|
"enabled_here": bool(enabled_here),
|
||||||
|
"profile_enabled": bool(profile.enabled),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
def _compose_urls(service, identifier, person_id):
|
def _compose_urls(service, identifier, person_id):
|
||||||
query = {"service": service, "identifier": identifier}
|
query = {"service": service, "identifier": identifier}
|
||||||
if person_id:
|
if person_id:
|
||||||
@@ -2092,6 +2331,11 @@ def _panel_context(
|
|||||||
user_id=request.user.id,
|
user_id=request.user.id,
|
||||||
person_id=base["person"].id if base["person"] else None,
|
person_id=base["person"].id if base["person"] else None,
|
||||||
)
|
)
|
||||||
|
command_options = _command_options_for_channel(
|
||||||
|
request.user,
|
||||||
|
base["service"],
|
||||||
|
base["identifier"],
|
||||||
|
)
|
||||||
recent_contacts = _recent_manual_contacts(
|
recent_contacts = _recent_manual_contacts(
|
||||||
request.user,
|
request.user,
|
||||||
current_service=base["service"],
|
current_service=base["service"],
|
||||||
@@ -2126,6 +2370,7 @@ def _panel_context(
|
|||||||
"compose_engage_send_url": reverse("compose_engage_send"),
|
"compose_engage_send_url": reverse("compose_engage_send"),
|
||||||
"compose_quick_insights_url": reverse("compose_quick_insights"),
|
"compose_quick_insights_url": reverse("compose_quick_insights"),
|
||||||
"compose_history_sync_url": reverse("compose_history_sync"),
|
"compose_history_sync_url": reverse("compose_history_sync"),
|
||||||
|
"compose_toggle_command_url": reverse("compose_toggle_command"),
|
||||||
"compose_ws_url": ws_url,
|
"compose_ws_url": ws_url,
|
||||||
"ai_workspace_url": (
|
"ai_workspace_url": (
|
||||||
f"{reverse('ai_workspace')}?person={base['person'].id}"
|
f"{reverse('ai_workspace')}?person={base['person'].id}"
|
||||||
@@ -2143,6 +2388,7 @@ def _panel_context(
|
|||||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||||
"panel_id": f"compose-panel-{unique}",
|
"panel_id": f"compose-panel-{unique}",
|
||||||
"typing_state_json": json.dumps(typing_state),
|
"typing_state_json": json.dumps(typing_state),
|
||||||
|
"command_options": command_options,
|
||||||
"platform_options": platform_options,
|
"platform_options": platform_options,
|
||||||
"recent_contacts": recent_contacts,
|
"recent_contacts": recent_contacts,
|
||||||
"is_group": base.get("is_group", False),
|
"is_group": base.get("is_group", False),
|
||||||
@@ -2577,6 +2823,7 @@ class ComposeThread(LoginRequiredMixin, View):
|
|||||||
"session",
|
"session",
|
||||||
"session__identifier",
|
"session__identifier",
|
||||||
"session__identifier__person",
|
"session__identifier__person",
|
||||||
|
"reply_to",
|
||||||
).order_by("-ts")[:limit]
|
).order_by("-ts")[:limit]
|
||||||
)
|
)
|
||||||
rows_desc.reverse()
|
rows_desc.reverse()
|
||||||
@@ -2979,6 +3226,89 @@ class ComposeCommandResult(LoginRequiredMixin, View):
|
|||||||
return JsonResponse({"pending": False, "result": result})
|
return JsonResponse({"pending": False, "result": result})
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeToggleCommand(LoginRequiredMixin, View):
|
||||||
|
def post(self, request):
|
||||||
|
service, identifier, _ = _request_scope(request, "POST")
|
||||||
|
channel_identifier = _canonical_command_channel_identifier(
|
||||||
|
service, str(identifier or "")
|
||||||
|
)
|
||||||
|
if not channel_identifier:
|
||||||
|
return JsonResponse({"ok": False, "error": "missing_identifier"}, status=400)
|
||||||
|
if service not in {"web", "xmpp", "signal", "whatsapp"}:
|
||||||
|
return JsonResponse(
|
||||||
|
{"ok": False, "error": f"unsupported_service:{service}"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
slug = str(request.POST.get("slug") or "bp").strip().lower() or "bp"
|
||||||
|
enabled = str(request.POST.get("enabled") or "1").strip().lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
}
|
||||||
|
ok, error = _toggle_command_for_channel(
|
||||||
|
user=request.user,
|
||||||
|
service=service,
|
||||||
|
identifier=channel_identifier,
|
||||||
|
slug=slug,
|
||||||
|
enabled=enabled,
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": error or "command_toggle_failed",
|
||||||
|
},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
command_options = _command_options_for_channel(
|
||||||
|
request.user, service, channel_identifier
|
||||||
|
)
|
||||||
|
for row in command_options:
|
||||||
|
if row.get("slug") == slug:
|
||||||
|
row["enabled_here"] = bool(enabled)
|
||||||
|
message = (
|
||||||
|
f"{slug} enabled for this chat."
|
||||||
|
if enabled
|
||||||
|
else f"{slug} disabled for this chat."
|
||||||
|
)
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"message": message,
|
||||||
|
"slug": slug,
|
||||||
|
"enabled": bool(enabled),
|
||||||
|
"command_options": command_options,
|
||||||
|
"settings_url": reverse("command_routing"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
class ComposeBindBP(ComposeToggleCommand):
|
||||||
|
def post(self, request):
|
||||||
|
service, identifier, _ = _request_scope(request, "POST")
|
||||||
|
ok, error = _toggle_command_for_channel(
|
||||||
|
user=request.user,
|
||||||
|
service=service,
|
||||||
|
identifier=str(identifier or ""),
|
||||||
|
slug="bp",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
return JsonResponse(
|
||||||
|
{"ok": False, "error": error or "command_toggle_failed"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"message": "bp enabled for this chat.",
|
||||||
|
"slug": "bp",
|
||||||
|
"enabled": True,
|
||||||
|
"settings_url": reverse("command_routing"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ComposeMediaBlob(LoginRequiredMixin, View):
|
class ComposeMediaBlob(LoginRequiredMixin, View):
|
||||||
"""
|
"""
|
||||||
Serve cached media blobs for authenticated compose image previews.
|
Serve cached media blobs for authenticated compose image previews.
|
||||||
@@ -3477,6 +3807,7 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
)
|
)
|
||||||
|
|
||||||
text = str(request.POST.get("text") or "").strip()
|
text = str(request.POST.get("text") or "").strip()
|
||||||
|
reply_to_message_id = str(request.POST.get("reply_to_message_id") or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return self._response(
|
return self._response(
|
||||||
request,
|
request,
|
||||||
@@ -3503,7 +3834,26 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
)
|
)
|
||||||
ts = None
|
ts = None
|
||||||
command_id = None
|
command_id = None
|
||||||
|
created_message = None
|
||||||
|
session = None
|
||||||
|
reply_to = None
|
||||||
|
if base["person_identifier"] is not None:
|
||||||
|
session, _ = ChatSession.objects.get_or_create(
|
||||||
|
user=request.user,
|
||||||
|
identifier=base["person_identifier"],
|
||||||
|
)
|
||||||
|
if reply_to_message_id:
|
||||||
|
reply_to = Message.objects.filter(
|
||||||
|
user=request.user,
|
||||||
|
session=session,
|
||||||
|
id=reply_to_message_id,
|
||||||
|
).first()
|
||||||
if runtime_client is None:
|
if runtime_client is None:
|
||||||
|
outbound_reply_metadata = {}
|
||||||
|
if base["service"] == "whatsapp":
|
||||||
|
outbound_reply_metadata = _build_whatsapp_reply_metadata(
|
||||||
|
reply_to, str(base["identifier"] or "")
|
||||||
|
)
|
||||||
if base["service"] == "whatsapp":
|
if base["service"] == "whatsapp":
|
||||||
runtime_state = transport.get_runtime_state("whatsapp")
|
runtime_state = transport.get_runtime_state("whatsapp")
|
||||||
last_seen = int(runtime_state.get("runtime_seen_at") or 0)
|
last_seen = int(runtime_state.get("runtime_seen_at") or 0)
|
||||||
@@ -3530,24 +3880,62 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
level="warning",
|
level="warning",
|
||||||
panel_id=panel_id,
|
panel_id=panel_id,
|
||||||
)
|
)
|
||||||
|
# Persist local message first so runtime can attach upstream bridge refs.
|
||||||
|
ts = int(time.time() * 1000)
|
||||||
|
if session is not None:
|
||||||
|
created_message = Message.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
session=session,
|
||||||
|
sender_uuid="",
|
||||||
|
text=text,
|
||||||
|
ts=int(ts),
|
||||||
|
delivered_ts=None,
|
||||||
|
custom_author="USER",
|
||||||
|
source_service="web",
|
||||||
|
source_message_id=str(ts),
|
||||||
|
source_chat_id=str(base["identifier"] or ""),
|
||||||
|
reply_to=reply_to,
|
||||||
|
reply_source_service="web" if reply_to is not None else None,
|
||||||
|
reply_source_message_id=(
|
||||||
|
str(reply_to.id) if reply_to is not None else None
|
||||||
|
),
|
||||||
|
message_meta={},
|
||||||
|
)
|
||||||
command_id = transport.enqueue_runtime_command(
|
command_id = transport.enqueue_runtime_command(
|
||||||
base["service"],
|
base["service"],
|
||||||
"send_message_raw",
|
"send_message_raw",
|
||||||
{"recipient": base["identifier"], "text": text, "attachments": []},
|
{
|
||||||
|
"recipient": base["identifier"],
|
||||||
|
"text": text,
|
||||||
|
"attachments": [],
|
||||||
|
"metadata": (
|
||||||
|
{
|
||||||
|
"legacy_message_id": str(created_message.id),
|
||||||
|
"local_ts": int(ts),
|
||||||
|
**outbound_reply_metadata,
|
||||||
|
}
|
||||||
|
if created_message is not None
|
||||||
|
else outbound_reply_metadata
|
||||||
|
),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
logger.debug(f"{log_prefix} command_id={command_id} enqueued")
|
logger.debug(f"{log_prefix} command_id={command_id} enqueued")
|
||||||
# attach command id to request so _response can include it in HX-Trigger
|
# attach command id to request so _response can include it in HX-Trigger
|
||||||
request._compose_command_id = command_id
|
request._compose_command_id = command_id
|
||||||
# Do NOT wait here — return immediately so the UI doesn't block.
|
# Do NOT wait here — return immediately so the UI doesn't block.
|
||||||
# Record a pending message locally so the thread shows the outgoing message.
|
|
||||||
ts = int(time.time() * 1000)
|
|
||||||
else:
|
else:
|
||||||
# In-process runtime can perform the send synchronously and return a timestamp.
|
# In-process runtime can perform the send synchronously and return a timestamp.
|
||||||
|
outbound_reply_metadata = {}
|
||||||
|
if base["service"] == "whatsapp":
|
||||||
|
outbound_reply_metadata = _build_whatsapp_reply_metadata(
|
||||||
|
reply_to, str(base["identifier"] or "")
|
||||||
|
)
|
||||||
ts = async_to_sync(transport.send_message_raw)(
|
ts = async_to_sync(transport.send_message_raw)(
|
||||||
base["service"],
|
base["service"],
|
||||||
base["identifier"],
|
base["identifier"],
|
||||||
text=text,
|
text=text,
|
||||||
attachments=[],
|
attachments=[],
|
||||||
|
metadata=outbound_reply_metadata,
|
||||||
)
|
)
|
||||||
# For queued sends we set `ts` to a local timestamp; for in-process sends ts may be False.
|
# For queued sends we set `ts` to a local timestamp; for in-process sends ts may be False.
|
||||||
if not ts:
|
if not ts:
|
||||||
@@ -3559,17 +3947,13 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
panel_id=panel_id,
|
panel_id=panel_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if base["person_identifier"] is not None:
|
if base["person_identifier"] is not None and created_message is None:
|
||||||
session, _ = ChatSession.objects.get_or_create(
|
|
||||||
user=request.user,
|
|
||||||
identifier=base["person_identifier"],
|
|
||||||
)
|
|
||||||
# For in-process sends (Signal, etc), ts is a timestamp or True.
|
# For in-process sends (Signal, etc), ts is a timestamp or True.
|
||||||
# For queued sends (WhatsApp/UR), ts is a local timestamp.
|
# For queued sends (WhatsApp/UR), ts is a local timestamp.
|
||||||
# Set delivered_ts only if we got a real timestamp OR if it's an in-process sync send.
|
# Set delivered_ts only if we got a real timestamp OR if it's an in-process sync send.
|
||||||
msg_ts = int(ts) if str(ts).isdigit() else int(time.time() * 1000)
|
msg_ts = int(ts) if str(ts).isdigit() else int(time.time() * 1000)
|
||||||
delivered_ts = msg_ts if runtime_client is not None else None
|
delivered_ts = msg_ts if runtime_client is not None else None
|
||||||
Message.objects.create(
|
created_message = Message.objects.create(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
session=session,
|
session=session,
|
||||||
sender_uuid="",
|
sender_uuid="",
|
||||||
@@ -3577,7 +3961,26 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
ts=msg_ts,
|
ts=msg_ts,
|
||||||
delivered_ts=delivered_ts,
|
delivered_ts=delivered_ts,
|
||||||
custom_author="USER",
|
custom_author="USER",
|
||||||
|
source_service="web",
|
||||||
|
source_message_id=str(msg_ts),
|
||||||
|
source_chat_id=str(base["identifier"] or ""),
|
||||||
|
reply_to=reply_to,
|
||||||
|
reply_source_service="web" if reply_to is not None else None,
|
||||||
|
reply_source_message_id=str(reply_to.id) if reply_to is not None else None,
|
||||||
|
message_meta={},
|
||||||
)
|
)
|
||||||
|
if created_message is not None:
|
||||||
|
async_to_sync(process_inbound_message)(
|
||||||
|
CommandContext(
|
||||||
|
service="web",
|
||||||
|
channel_identifier=str(base["identifier"] or ""),
|
||||||
|
message_id=str(created_message.id),
|
||||||
|
user_id=int(request.user.id),
|
||||||
|
message_text=text,
|
||||||
|
payload={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async_to_sync(process_inbound_translation)(created_message)
|
||||||
# Notify XMPP clients from runtime so cross-platform sends appear there too.
|
# Notify XMPP clients from runtime so cross-platform sends appear there too.
|
||||||
if base["service"] in {"signal", "whatsapp"}:
|
if base["service"] in {"signal", "whatsapp"}:
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user