Implement business plans

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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