Fix Signal messages and replies
This commit is contained in:
56
Makefile
56
Makefile
@@ -1,10 +1,15 @@
|
|||||||
QUADLET_MGR := ./scripts/quadlet/manage.sh
|
QUADLET_MGR := ./scripts/quadlet/manage.sh
|
||||||
|
MODULES ?= core.tests
|
||||||
|
|
||||||
run:
|
run:
|
||||||
bash $(QUADLET_MGR) up
|
bash $(QUADLET_MGR) up
|
||||||
|
|
||||||
build:
|
build:
|
||||||
OPERATION=uwsgi podman build --build-arg OPERATION=uwsgi -t localhost/xf/gia:prod -f Dockerfile .
|
@if command -v docker-compose >/dev/null 2>&1; then \
|
||||||
|
docker-compose --env-file=stack.env build app; \
|
||||||
|
else \
|
||||||
|
OPERATION=uwsgi podman build --build-arg OPERATION=uwsgi -t localhost/xf/gia:prod -f Dockerfile .; \
|
||||||
|
fi
|
||||||
|
|
||||||
stop:
|
stop:
|
||||||
bash $(QUADLET_MGR) down
|
bash $(QUADLET_MGR) down
|
||||||
@@ -31,17 +36,58 @@ test:
|
|||||||
@if command -v docker-compose >/dev/null 2>&1; then \
|
@if command -v docker-compose >/dev/null 2>&1; then \
|
||||||
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py test $(MODULES) -v 2"; \
|
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py test $(MODULES) -v 2"; \
|
||||||
else \
|
else \
|
||||||
|
if podman ps --format '{{.Names}}' | grep -qx gia; then \
|
||||||
podman exec gia sh -lc "cd /code && . /venv/bin/activate && python manage.py test $(MODULES) -v 2"; \
|
podman exec gia sh -lc "cd /code && . /venv/bin/activate && python manage.py test $(MODULES) -v 2"; \
|
||||||
|
else \
|
||||||
|
echo "Container 'gia' is not running. Start the stack first with 'make run' (or mrl)." >&2; \
|
||||||
|
exit 125; \
|
||||||
|
fi; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py migrate"
|
@if command -v docker-compose >/dev/null 2>&1; then \
|
||||||
|
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py migrate"; \
|
||||||
|
else \
|
||||||
|
if podman ps --format '{{.Names}}' | grep -qx gia; then \
|
||||||
|
podman exec gia sh -lc "cd /code && . /venv/bin/activate && python manage.py migrate"; \
|
||||||
|
else \
|
||||||
|
echo "Container 'gia' is not running. Start the stack first with 'make run' (or mrl)." >&2; \
|
||||||
|
exit 125; \
|
||||||
|
fi; \
|
||||||
|
fi
|
||||||
|
|
||||||
makemigrations:
|
makemigrations:
|
||||||
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py makemigrations"
|
@if command -v docker-compose >/dev/null 2>&1; then \
|
||||||
|
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py makemigrations"; \
|
||||||
|
else \
|
||||||
|
if podman ps --format '{{.Names}}' | grep -qx gia; then \
|
||||||
|
podman exec gia sh -lc "cd /code && . /venv/bin/activate && python manage.py makemigrations"; \
|
||||||
|
else \
|
||||||
|
echo "Container 'gia' is not running. Start the stack first with 'make run' (or mrl)." >&2; \
|
||||||
|
exit 125; \
|
||||||
|
fi; \
|
||||||
|
fi
|
||||||
|
|
||||||
auth:
|
auth:
|
||||||
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py createsuperuser"
|
@if command -v docker-compose >/dev/null 2>&1; then \
|
||||||
|
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py createsuperuser"; \
|
||||||
|
else \
|
||||||
|
if podman ps --format '{{.Names}}' | grep -qx gia; then \
|
||||||
|
podman exec gia sh -lc "cd /code && . /venv/bin/activate && python manage.py createsuperuser"; \
|
||||||
|
else \
|
||||||
|
echo "Container 'gia' is not running. Start the stack first with 'make run' (or mrl)." >&2; \
|
||||||
|
exit 125; \
|
||||||
|
fi; \
|
||||||
|
fi
|
||||||
|
|
||||||
token:
|
token:
|
||||||
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py addstatictoken m"
|
@if command -v docker-compose >/dev/null 2>&1; then \
|
||||||
|
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py addstatictoken m"; \
|
||||||
|
else \
|
||||||
|
if podman ps --format '{{.Names}}' | grep -qx gia; then \
|
||||||
|
podman exec gia sh -lc "cd /code && . /venv/bin/activate && python manage.py addstatictoken m"; \
|
||||||
|
else \
|
||||||
|
echo "Container 'gia' is not running. Start the stack first with 'make run' (or mrl)." >&2; \
|
||||||
|
exit 125; \
|
||||||
|
fi; \
|
||||||
|
fi
|
||||||
|
|||||||
@@ -147,6 +147,11 @@ urlpatterns = [
|
|||||||
signal.SignalAccountAdd.as_view(),
|
signal.SignalAccountAdd.as_view(),
|
||||||
name="signal_account_add",
|
name="signal_account_add",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"services/signal/<str:type>/unlink/<path:account>/",
|
||||||
|
signal.SignalAccountUnlink.as_view(),
|
||||||
|
name="signal_account_unlink",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"services/whatsapp/<str:type>/add/",
|
"services/whatsapp/<str:type>/add/",
|
||||||
whatsapp.WhatsAppAccountAdd.as_view(),
|
whatsapp.WhatsAppAccountAdd.as_view(),
|
||||||
|
|||||||
@@ -5,14 +5,23 @@ import time
|
|||||||
from urllib.parse import quote_plus, urlparse
|
from urllib.parse import quote_plus, urlparse
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import websockets
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from signalbot import Command, Context, SignalBot
|
from signalbot import Command, Context, SignalBot
|
||||||
|
|
||||||
from core.clients import ClientBase, signalapi
|
from core.clients import ClientBase, signalapi, transport
|
||||||
from core.messaging import ai, history, media_bridge, natural, replies, reply_sync, utils
|
from core.messaging import ai, history, media_bridge, natural, replies, reply_sync, utils
|
||||||
from core.models import Chat, Manipulation, PersonIdentifier, PlatformChatLink, QueuedMessage
|
from core.models import (
|
||||||
|
Chat,
|
||||||
|
Manipulation,
|
||||||
|
Message,
|
||||||
|
Person,
|
||||||
|
PersonIdentifier,
|
||||||
|
PlatformChatLink,
|
||||||
|
QueuedMessage,
|
||||||
|
)
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
|
||||||
log = logs.get_logger("signalF")
|
log = logs.get_logger("signalF")
|
||||||
@@ -214,6 +223,10 @@ def _identifier_candidates(*values):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _digits_only(value):
|
||||||
|
return re.sub(r"[^0-9]", "", str(value or "").strip())
|
||||||
|
|
||||||
|
|
||||||
class NewSignalBot(SignalBot):
|
class NewSignalBot(SignalBot):
|
||||||
def __init__(self, ur, service, config):
|
def __init__(self, ur, service, config):
|
||||||
self.ur = ur
|
self.ur = ur
|
||||||
@@ -358,6 +371,12 @@ class HandleMessage(Command):
|
|||||||
ts = c.message.timestamp
|
ts = c.message.timestamp
|
||||||
source_value = c.message.source
|
source_value = c.message.source
|
||||||
envelope = raw.get("envelope", {})
|
envelope = raw.get("envelope", {})
|
||||||
|
envelope_source_uuid = envelope.get("sourceUuid")
|
||||||
|
envelope_source_number = envelope.get("sourceNumber")
|
||||||
|
effective_source_uuid = str(envelope_source_uuid or source_uuid or "").strip()
|
||||||
|
effective_source_number = str(
|
||||||
|
envelope_source_number or source_number or ""
|
||||||
|
).strip()
|
||||||
signal_source_message_id = str(
|
signal_source_message_id = str(
|
||||||
envelope.get("serverGuid")
|
envelope.get("serverGuid")
|
||||||
or envelope.get("guid")
|
or envelope.get("guid")
|
||||||
@@ -369,21 +388,29 @@ class HandleMessage(Command):
|
|||||||
|
|
||||||
bot_uuid = str(getattr(c.bot, "bot_uuid", "") or "").strip()
|
bot_uuid = str(getattr(c.bot, "bot_uuid", "") or "").strip()
|
||||||
bot_phone = str(getattr(c.bot, "phone_number", "") or "").strip()
|
bot_phone = str(getattr(c.bot, "phone_number", "") or "").strip()
|
||||||
source_uuid_norm = str(source_uuid or "").strip()
|
source_uuid_norm = effective_source_uuid
|
||||||
source_number_norm = str(source_number or "").strip()
|
source_number_norm = effective_source_number
|
||||||
dest_norm = str(dest or "").strip()
|
dest_norm = str(dest or "").strip()
|
||||||
destination_number_norm = str(destination_number or "").strip()
|
destination_number_norm = str(destination_number or "").strip()
|
||||||
|
|
||||||
bot_phone_digits = re.sub(r"[^0-9]", "", bot_phone)
|
bot_phone_digits = re.sub(r"[^0-9]", "", bot_phone)
|
||||||
source_phone_digits = re.sub(r"[^0-9]", "", source_number_norm)
|
source_phone_digits = re.sub(r"[^0-9]", "", source_number_norm)
|
||||||
dest_phone_digits = re.sub(r"[^0-9]", "", destination_number_norm or dest_norm)
|
dest_phone_digits = re.sub(r"[^0-9]", "", destination_number_norm or dest_norm)
|
||||||
|
is_sync_outbound = bool(dest_norm or destination_number_norm)
|
||||||
|
|
||||||
# Message originating from us
|
# Message originating from us
|
||||||
same_recipient = source_uuid == dest
|
same_recipient = bool(
|
||||||
|
source_uuid_norm and dest_norm and source_uuid_norm == dest_norm
|
||||||
|
)
|
||||||
|
|
||||||
is_from_bot = bool(bot_uuid and source_uuid_norm and source_uuid_norm == bot_uuid)
|
is_from_bot = bool(bot_uuid and source_uuid_norm and source_uuid_norm == bot_uuid)
|
||||||
if (not is_from_bot) and bot_phone_digits and source_phone_digits:
|
if (not is_from_bot) and bot_phone_digits and source_phone_digits:
|
||||||
is_from_bot = source_phone_digits == bot_phone_digits
|
is_from_bot = source_phone_digits == bot_phone_digits
|
||||||
|
# Inbound deliveries usually do not have destination fields populated.
|
||||||
|
# When destination is missing, treat event as inbound even if source
|
||||||
|
# metadata drifts to our own identifiers.
|
||||||
|
if not is_sync_outbound:
|
||||||
|
is_from_bot = False
|
||||||
|
|
||||||
# For non-sync incoming events destination is usually absent and points to us.
|
# For non-sync incoming events destination is usually absent and points to us.
|
||||||
is_to_bot = bool(bot_uuid and dest_norm and dest_norm == bot_uuid)
|
is_to_bot = bool(bot_uuid and dest_norm and dest_norm == bot_uuid)
|
||||||
@@ -396,12 +423,10 @@ class HandleMessage(Command):
|
|||||||
reply_to_others = is_to_bot and not same_recipient # Reply
|
reply_to_others = is_to_bot and not same_recipient # Reply
|
||||||
is_outgoing_message = is_from_bot and not is_to_bot # Do not reply
|
is_outgoing_message = is_from_bot and not is_to_bot # Do not reply
|
||||||
|
|
||||||
envelope_source_uuid = envelope.get("sourceUuid")
|
|
||||||
envelope_source_number = envelope.get("sourceNumber")
|
|
||||||
envelope_source = envelope.get("source")
|
envelope_source = envelope.get("source")
|
||||||
|
|
||||||
primary_identifier = dest if is_from_bot else source_uuid
|
primary_identifier = dest if is_from_bot else effective_source_uuid
|
||||||
if dest or destination_number:
|
if (dest or destination_number) and is_from_bot:
|
||||||
# Sync "sentMessage" events are outbound; route by destination only.
|
# Sync "sentMessage" events are outbound; route by destination only.
|
||||||
# This prevents copying one outbound message into multiple people
|
# This prevents copying one outbound message into multiple people
|
||||||
# when source fields include the bot's own identifier.
|
# when source fields include the bot's own identifier.
|
||||||
@@ -415,8 +440,8 @@ class HandleMessage(Command):
|
|||||||
}
|
}
|
||||||
incoming_candidates = _identifier_candidates(
|
incoming_candidates = _identifier_candidates(
|
||||||
primary_identifier,
|
primary_identifier,
|
||||||
source_uuid,
|
effective_source_uuid,
|
||||||
source_number,
|
effective_source_number,
|
||||||
source_value,
|
source_value,
|
||||||
envelope_source_uuid,
|
envelope_source_uuid,
|
||||||
envelope_source_number,
|
envelope_source_number,
|
||||||
@@ -438,6 +463,104 @@ class HandleMessage(Command):
|
|||||||
service=self.service,
|
service=self.service,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if not identifiers:
|
||||||
|
companion_candidates = []
|
||||||
|
for value in identifier_candidates:
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
companions = await sync_to_async(list)(
|
||||||
|
Chat.objects.filter(source_uuid=value).values_list(
|
||||||
|
"source_number", flat=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
companions += await sync_to_async(list)(
|
||||||
|
Chat.objects.filter(source_number=value).values_list(
|
||||||
|
"source_uuid", flat=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
companion_candidates.extend(companions)
|
||||||
|
companion_candidates = _identifier_candidates(*companion_candidates)
|
||||||
|
if companion_candidates:
|
||||||
|
identifiers = await sync_to_async(list)(
|
||||||
|
PersonIdentifier.objects.filter(
|
||||||
|
identifier__in=companion_candidates,
|
||||||
|
service=self.service,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not identifiers:
|
||||||
|
# Final fallback: compare normalized phone digits to handle format drift
|
||||||
|
# between Signal payload values and stored identifiers.
|
||||||
|
candidate_digits = {_digits_only(value) for value in identifier_candidates}
|
||||||
|
candidate_digits = {value for value in candidate_digits if value}
|
||||||
|
if candidate_digits:
|
||||||
|
signal_rows = await sync_to_async(list)(
|
||||||
|
PersonIdentifier.objects.filter(service=self.service).select_related(
|
||||||
|
"user"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
matched = []
|
||||||
|
for row in signal_rows:
|
||||||
|
stored_digits = _digits_only(row.identifier)
|
||||||
|
if stored_digits and stored_digits in candidate_digits:
|
||||||
|
matched.append(row)
|
||||||
|
identifiers = matched
|
||||||
|
if not identifiers and (not is_from_bot) and (not bool(c.message.group)):
|
||||||
|
# Single-user fallback: don't drop new private inbound contacts just
|
||||||
|
# because they are not pre-linked yet. Create a placeholder person +
|
||||||
|
# identifier so the chat appears and can be re-linked later.
|
||||||
|
owner_rows = await sync_to_async(list)(
|
||||||
|
PersonIdentifier.objects.filter(service=self.service)
|
||||||
|
.select_related("user")
|
||||||
|
.order_by("user_id", "id")
|
||||||
|
)
|
||||||
|
owner_users = []
|
||||||
|
seen_user_ids = set()
|
||||||
|
for row in owner_rows:
|
||||||
|
if row.user_id in seen_user_ids:
|
||||||
|
continue
|
||||||
|
seen_user_ids.add(row.user_id)
|
||||||
|
owner_users.append(row.user)
|
||||||
|
if len(owner_users) == 1:
|
||||||
|
owner = owner_users[0]
|
||||||
|
fallback_identifier = (
|
||||||
|
effective_source_number
|
||||||
|
or effective_source_uuid
|
||||||
|
or (identifier_candidates[0] if identifier_candidates else "")
|
||||||
|
)
|
||||||
|
fallback_identifier = str(fallback_identifier or "").strip()
|
||||||
|
if fallback_identifier:
|
||||||
|
person, _ = await sync_to_async(Person.objects.get_or_create)(
|
||||||
|
user=owner,
|
||||||
|
name=f"Signal {fallback_identifier}",
|
||||||
|
)
|
||||||
|
pi, _ = await sync_to_async(PersonIdentifier.objects.get_or_create)(
|
||||||
|
user=owner,
|
||||||
|
service=self.service,
|
||||||
|
identifier=fallback_identifier,
|
||||||
|
defaults={"person": person},
|
||||||
|
)
|
||||||
|
if pi.person_id != person.id:
|
||||||
|
pi.person = person
|
||||||
|
await sync_to_async(pi.save)(update_fields=["person"])
|
||||||
|
identifiers = [pi]
|
||||||
|
log.info(
|
||||||
|
"Signal inbound auto-linked new private contact identifier=%s user_id=%s",
|
||||||
|
fallback_identifier,
|
||||||
|
int(owner.id),
|
||||||
|
)
|
||||||
|
if not identifiers:
|
||||||
|
log.warning(
|
||||||
|
"Signal inbound unmatched: candidates=%s source_uuid=%s source_number=%s effective_source_uuid=%s effective_source_number=%s dest=%s destination_number=%s envelope_source_uuid=%s envelope_source_number=%s",
|
||||||
|
identifier_candidates,
|
||||||
|
str(source_uuid or ""),
|
||||||
|
str(source_number or ""),
|
||||||
|
str(effective_source_uuid or ""),
|
||||||
|
str(effective_source_number or ""),
|
||||||
|
str(dest or ""),
|
||||||
|
str(destination_number or ""),
|
||||||
|
str(envelope_source_uuid or ""),
|
||||||
|
str(envelope_source_number or ""),
|
||||||
|
)
|
||||||
|
|
||||||
typing_payload = envelope.get("typingMessage")
|
typing_payload = envelope.get("typingMessage")
|
||||||
if isinstance(typing_payload, dict):
|
if isinstance(typing_payload, dict):
|
||||||
@@ -471,7 +594,7 @@ class HandleMessage(Command):
|
|||||||
message_timestamps=read_timestamps,
|
message_timestamps=read_timestamps,
|
||||||
read_ts=read_ts,
|
read_ts=read_ts,
|
||||||
payload=receipt_payload,
|
payload=receipt_payload,
|
||||||
read_by=(source_uuid or source_number or ""),
|
read_by=(effective_source_uuid or effective_source_number or ""),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -493,7 +616,9 @@ class HandleMessage(Command):
|
|||||||
target_ts=int(reaction_payload.get("target_ts") or 0),
|
target_ts=int(reaction_payload.get("target_ts") or 0),
|
||||||
emoji=str(reaction_payload.get("emoji") or ""),
|
emoji=str(reaction_payload.get("emoji") or ""),
|
||||||
source_service="signal",
|
source_service="signal",
|
||||||
actor=(source_uuid or source_number or ""),
|
actor=(
|
||||||
|
effective_source_uuid or effective_source_number or ""
|
||||||
|
),
|
||||||
remove=bool(reaction_payload.get("remove")),
|
remove=bool(reaction_payload.get("remove")),
|
||||||
payload=reaction_payload.get("raw") or {},
|
payload=reaction_payload.get("raw") or {},
|
||||||
)
|
)
|
||||||
@@ -508,7 +633,9 @@ class HandleMessage(Command):
|
|||||||
remove=bool(reaction_payload.get("remove")),
|
remove=bool(reaction_payload.get("remove")),
|
||||||
upstream_message_id="",
|
upstream_message_id="",
|
||||||
upstream_ts=int(reaction_payload.get("target_ts") or 0),
|
upstream_ts=int(reaction_payload.get("target_ts") or 0),
|
||||||
actor=(source_uuid or source_number or ""),
|
actor=(
|
||||||
|
effective_source_uuid or effective_source_number or ""
|
||||||
|
),
|
||||||
payload=reaction_payload.get("raw") or {},
|
payload=reaction_payload.get("raw") or {},
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -604,7 +731,11 @@ class HandleMessage(Command):
|
|||||||
attachments=xmpp_attachments,
|
attachments=xmpp_attachments,
|
||||||
source_ref={
|
source_ref={
|
||||||
"upstream_message_id": "",
|
"upstream_message_id": "",
|
||||||
"upstream_author": str(source_uuid or source_number or ""),
|
"upstream_author": str(
|
||||||
|
effective_source_uuid
|
||||||
|
or effective_source_number
|
||||||
|
or ""
|
||||||
|
),
|
||||||
"upstream_ts": int(ts or 0),
|
"upstream_ts": int(ts or 0),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -626,7 +757,9 @@ class HandleMessage(Command):
|
|||||||
attachments=xmpp_attachments,
|
attachments=xmpp_attachments,
|
||||||
source_ref={
|
source_ref={
|
||||||
"upstream_message_id": "",
|
"upstream_message_id": "",
|
||||||
"upstream_author": str(source_uuid or source_number or ""),
|
"upstream_author": str(
|
||||||
|
effective_source_uuid or effective_source_number or ""
|
||||||
|
),
|
||||||
"upstream_ts": int(ts or 0),
|
"upstream_ts": int(ts or 0),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -656,7 +789,11 @@ class HandleMessage(Command):
|
|||||||
chat_session,
|
chat_session,
|
||||||
reply_ref,
|
reply_ref,
|
||||||
)
|
)
|
||||||
sender_key = source_uuid or source_number or identifier_candidates[0]
|
sender_key = (
|
||||||
|
effective_source_uuid
|
||||||
|
or effective_source_number
|
||||||
|
or identifier_candidates[0]
|
||||||
|
)
|
||||||
message_key = (chat_session.id, ts, sender_key)
|
message_key = (chat_session.id, ts, sender_key)
|
||||||
message_text = identifier_text_overrides.get(session_key, relay_text)
|
message_text = identifier_text_overrides.get(session_key, relay_text)
|
||||||
if message_key not in stored_messages:
|
if message_key not in stored_messages:
|
||||||
@@ -797,18 +934,18 @@ class HandleMessage(Command):
|
|||||||
log.error(f"Mode {manip.mode} is not implemented")
|
log.error(f"Mode {manip.mode} is not implemented")
|
||||||
|
|
||||||
chat_lookup = {"account": account}
|
chat_lookup = {"account": account}
|
||||||
if source_uuid:
|
if effective_source_uuid:
|
||||||
chat_lookup["source_uuid"] = source_uuid
|
chat_lookup["source_uuid"] = effective_source_uuid
|
||||||
elif source_number:
|
elif effective_source_number:
|
||||||
chat_lookup["source_number"] = source_number
|
chat_lookup["source_number"] = effective_source_number
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
await sync_to_async(Chat.objects.update_or_create)(
|
await sync_to_async(Chat.objects.update_or_create)(
|
||||||
**chat_lookup,
|
**chat_lookup,
|
||||||
defaults={
|
defaults={
|
||||||
"source_uuid": source_uuid,
|
"source_uuid": effective_source_uuid,
|
||||||
"source_number": source_number,
|
"source_number": effective_source_number,
|
||||||
"source_name": source_name,
|
"source_name": source_name,
|
||||||
"account": account,
|
"account": account,
|
||||||
},
|
},
|
||||||
@@ -831,6 +968,7 @@ class SignalClient(ClientBase):
|
|||||||
|
|
||||||
self.client.register(HandleMessage(self.ur, self.service))
|
self.client.register(HandleMessage(self.ur, self.service))
|
||||||
self._command_task = None
|
self._command_task = None
|
||||||
|
self._raw_receive_task = None
|
||||||
|
|
||||||
async def _drain_runtime_commands(self):
|
async def _drain_runtime_commands(self):
|
||||||
"""Process queued runtime commands (e.g., web UI sends via composite router)."""
|
"""Process queued runtime commands (e.g., web UI sends via composite router)."""
|
||||||
@@ -857,11 +995,13 @@ class SignalClient(ClientBase):
|
|||||||
recipient = str(payload.get("recipient") or "").strip()
|
recipient = str(payload.get("recipient") or "").strip()
|
||||||
text = payload.get("text")
|
text = payload.get("text")
|
||||||
attachments = payload.get("attachments") or []
|
attachments = payload.get("attachments") or []
|
||||||
|
metadata = dict(payload.get("metadata") or {})
|
||||||
try:
|
try:
|
||||||
result = await signalapi.send_message_raw(
|
result = await signalapi.send_message_raw(
|
||||||
recipient_uuid=recipient,
|
recipient_uuid=recipient,
|
||||||
text=text,
|
text=text,
|
||||||
attachments=attachments,
|
attachments=attachments,
|
||||||
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
if result is False or result is None:
|
if result is False or result is None:
|
||||||
raise RuntimeError("signal_send_failed")
|
raise RuntimeError("signal_send_failed")
|
||||||
@@ -947,10 +1087,387 @@ class SignalClient(ClientBase):
|
|||||||
self.log.warning(f"Command loop error: {exc}")
|
self.log.warning(f"Command loop error: {exc}")
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
async def _resolve_signal_identifiers(self, source_uuid: str, source_number: str):
|
||||||
|
candidates = _identifier_candidates(source_uuid, source_number)
|
||||||
|
if not candidates:
|
||||||
|
return []
|
||||||
|
identifiers = await sync_to_async(list)(
|
||||||
|
PersonIdentifier.objects.filter(
|
||||||
|
identifier__in=candidates,
|
||||||
|
service=self.service,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if identifiers:
|
||||||
|
return identifiers
|
||||||
|
candidate_digits = {_digits_only(value) for value in candidates}
|
||||||
|
candidate_digits = {value for value in candidate_digits if value}
|
||||||
|
if not candidate_digits:
|
||||||
|
return []
|
||||||
|
rows = await sync_to_async(list)(
|
||||||
|
PersonIdentifier.objects.filter(service=self.service).select_related("user")
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
row
|
||||||
|
for row in rows
|
||||||
|
if _digits_only(getattr(row, "identifier", "")) in candidate_digits
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _auto_link_single_user_signal_identifier(self, source_uuid: str, source_number: str):
|
||||||
|
owner_rows = await sync_to_async(list)(
|
||||||
|
PersonIdentifier.objects.filter(service=self.service)
|
||||||
|
.select_related("user")
|
||||||
|
.order_by("user_id", "id")
|
||||||
|
)
|
||||||
|
users = []
|
||||||
|
seen = set()
|
||||||
|
for row in owner_rows:
|
||||||
|
if row.user_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(row.user_id)
|
||||||
|
users.append(row.user)
|
||||||
|
if len(users) != 1:
|
||||||
|
return []
|
||||||
|
owner = users[0]
|
||||||
|
fallback_identifier = str(source_number or source_uuid or "").strip()
|
||||||
|
if not fallback_identifier:
|
||||||
|
return []
|
||||||
|
person, _ = await sync_to_async(Person.objects.get_or_create)(
|
||||||
|
user=owner,
|
||||||
|
name=f"Signal {fallback_identifier}",
|
||||||
|
)
|
||||||
|
pi, _ = await sync_to_async(PersonIdentifier.objects.get_or_create)(
|
||||||
|
user=owner,
|
||||||
|
service=self.service,
|
||||||
|
identifier=fallback_identifier,
|
||||||
|
defaults={"person": person},
|
||||||
|
)
|
||||||
|
if pi.person_id != person.id:
|
||||||
|
pi.person = person
|
||||||
|
await sync_to_async(pi.save)(update_fields=["person"])
|
||||||
|
self.log.info(
|
||||||
|
"signal raw-receive auto-linked identifier=%s user_id=%s",
|
||||||
|
fallback_identifier,
|
||||||
|
int(owner.id),
|
||||||
|
)
|
||||||
|
return [pi]
|
||||||
|
|
||||||
|
async def _process_raw_inbound_event(self, raw_message: str):
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw_message or "{}")
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
exception_payload = payload.get("exception") if isinstance(payload, dict) else None
|
||||||
|
if isinstance(exception_payload, dict):
|
||||||
|
err_type = str(exception_payload.get("type") or "").strip()
|
||||||
|
err_msg = str(exception_payload.get("message") or "").strip()
|
||||||
|
envelope = payload.get("envelope") or {}
|
||||||
|
envelope_source_uuid = ""
|
||||||
|
envelope_source_number = ""
|
||||||
|
envelope_ts = 0
|
||||||
|
envelope_keys = []
|
||||||
|
if isinstance(envelope, dict):
|
||||||
|
envelope_source_uuid = str(envelope.get("sourceUuid") or "").strip()
|
||||||
|
envelope_source_number = str(envelope.get("sourceNumber") or "").strip()
|
||||||
|
try:
|
||||||
|
envelope_ts = int(
|
||||||
|
envelope.get("timestamp")
|
||||||
|
or envelope.get("serverReceivedTimestamp")
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
envelope_ts = 0
|
||||||
|
envelope_keys = sorted(list(envelope.keys()))[:20]
|
||||||
|
payload_excerpt = json.dumps(payload, ensure_ascii=True)[:1200]
|
||||||
|
transport.update_runtime_state(
|
||||||
|
self.service,
|
||||||
|
last_inbound_exception_type=err_type,
|
||||||
|
last_inbound_exception_message=err_msg,
|
||||||
|
last_inbound_exception_ts=int(
|
||||||
|
(envelope.get("timestamp") if isinstance(envelope, dict) else 0)
|
||||||
|
or int(time.time() * 1000)
|
||||||
|
),
|
||||||
|
last_inbound_exception_account=str(payload.get("account") or "").strip(),
|
||||||
|
last_inbound_exception_source_uuid=envelope_source_uuid,
|
||||||
|
last_inbound_exception_source_number=envelope_source_number,
|
||||||
|
last_inbound_exception_envelope_ts=envelope_ts,
|
||||||
|
last_inbound_exception_envelope_keys=envelope_keys,
|
||||||
|
last_inbound_exception_payload_excerpt=payload_excerpt,
|
||||||
|
)
|
||||||
|
self.log.warning(
|
||||||
|
"signal raw-receive exception type=%s message=%s source_uuid=%s source_number=%s envelope_ts=%s",
|
||||||
|
err_type or "-",
|
||||||
|
err_msg or "-",
|
||||||
|
envelope_source_uuid or "-",
|
||||||
|
envelope_source_number or "-",
|
||||||
|
envelope_ts or 0,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
envelope = payload.get("envelope") or {}
|
||||||
|
if not isinstance(envelope, dict):
|
||||||
|
return
|
||||||
|
sync_sent_message = _get_nested(envelope, ("syncMessage", "sentMessage")) or {}
|
||||||
|
if isinstance(sync_sent_message, dict) and sync_sent_message:
|
||||||
|
raw_text = sync_sent_message.get("message")
|
||||||
|
if isinstance(raw_text, dict):
|
||||||
|
text = str(
|
||||||
|
raw_text.get("message")
|
||||||
|
or raw_text.get("text")
|
||||||
|
or raw_text.get("body")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
else:
|
||||||
|
text = str(raw_text or "").strip()
|
||||||
|
|
||||||
|
destination_uuid = str(
|
||||||
|
sync_sent_message.get("destinationUuid")
|
||||||
|
or sync_sent_message.get("destination")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
destination_number = str(
|
||||||
|
sync_sent_message.get("destinationNumber")
|
||||||
|
or sync_sent_message.get("destinationE164")
|
||||||
|
or sync_sent_message.get("destination")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
identifiers = await self._resolve_signal_identifiers(
|
||||||
|
destination_uuid,
|
||||||
|
destination_number,
|
||||||
|
)
|
||||||
|
if not identifiers:
|
||||||
|
identifiers = await self._auto_link_single_user_signal_identifier(
|
||||||
|
destination_uuid,
|
||||||
|
destination_number,
|
||||||
|
)
|
||||||
|
if identifiers and text:
|
||||||
|
ts_raw = (
|
||||||
|
sync_sent_message.get("timestamp")
|
||||||
|
or envelope.get("timestamp")
|
||||||
|
or envelope.get("serverReceivedTimestamp")
|
||||||
|
or int(time.time() * 1000)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ts = int(ts_raw)
|
||||||
|
except Exception:
|
||||||
|
ts = int(time.time() * 1000)
|
||||||
|
source_message_id = str(
|
||||||
|
envelope.get("serverGuid")
|
||||||
|
or envelope.get("guid")
|
||||||
|
or envelope.get("timestamp")
|
||||||
|
or ts
|
||||||
|
).strip()
|
||||||
|
sender_key = (
|
||||||
|
str(getattr(self.client, "bot_uuid", "") or "").strip()
|
||||||
|
or str(getattr(self.client, "phone_number", "") or "").strip()
|
||||||
|
or str(payload.get("account") or "").strip()
|
||||||
|
or "self"
|
||||||
|
)
|
||||||
|
source_chat_id = destination_number or destination_uuid or sender_key
|
||||||
|
reply_ref = reply_sync.extract_reply_ref(self.service, payload)
|
||||||
|
for identifier in identifiers:
|
||||||
|
session = await history.get_chat_session(identifier.user, identifier)
|
||||||
|
reply_target = await reply_sync.resolve_reply_target(
|
||||||
|
identifier.user,
|
||||||
|
session,
|
||||||
|
reply_ref,
|
||||||
|
)
|
||||||
|
exists = await sync_to_async(
|
||||||
|
lambda: Message.objects.filter(
|
||||||
|
user=identifier.user,
|
||||||
|
session=session,
|
||||||
|
source_service=self.service,
|
||||||
|
source_message_id=source_message_id,
|
||||||
|
).exists()
|
||||||
|
)()
|
||||||
|
if exists:
|
||||||
|
continue
|
||||||
|
await history.store_message(
|
||||||
|
session=session,
|
||||||
|
sender=sender_key,
|
||||||
|
text=text,
|
||||||
|
ts=ts,
|
||||||
|
outgoing=True,
|
||||||
|
source_service=self.service,
|
||||||
|
source_message_id=source_message_id,
|
||||||
|
source_chat_id=source_chat_id,
|
||||||
|
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={},
|
||||||
|
)
|
||||||
|
transport.update_runtime_state(
|
||||||
|
self.service,
|
||||||
|
last_inbound_ok_ts=int(time.time() * 1000),
|
||||||
|
last_inbound_exception_type="",
|
||||||
|
last_inbound_exception_message="",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if envelope.get("typingMessage") or envelope.get("receiptMessage"):
|
||||||
|
return
|
||||||
|
data_message = envelope.get("dataMessage") or {}
|
||||||
|
if not isinstance(data_message, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
source_uuid = str(envelope.get("sourceUuid") or envelope.get("source") or "").strip()
|
||||||
|
source_number = str(envelope.get("sourceNumber") or "").strip()
|
||||||
|
bot_uuid = str(getattr(self.client, "bot_uuid", "") or "").strip()
|
||||||
|
bot_phone = str(getattr(self.client, "phone_number", "") or "").strip()
|
||||||
|
if source_uuid and bot_uuid and source_uuid == bot_uuid:
|
||||||
|
return
|
||||||
|
if source_number and bot_phone and _digits_only(source_number) == _digits_only(bot_phone):
|
||||||
|
return
|
||||||
|
|
||||||
|
identifiers = await self._resolve_signal_identifiers(source_uuid, source_number)
|
||||||
|
if not identifiers:
|
||||||
|
identifiers = await self._auto_link_single_user_signal_identifier(
|
||||||
|
source_uuid, source_number
|
||||||
|
)
|
||||||
|
if not identifiers:
|
||||||
|
self.log.warning(
|
||||||
|
"signal raw-receive unmatched source_uuid=%s source_number=%s text=%s",
|
||||||
|
source_uuid,
|
||||||
|
source_number,
|
||||||
|
str(data_message.get("message") or "")[:160],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
reaction_payload = _extract_signal_reaction(envelope)
|
||||||
|
if isinstance(reaction_payload, dict):
|
||||||
|
for identifier in identifiers:
|
||||||
|
try:
|
||||||
|
await history.apply_reaction(
|
||||||
|
identifier.user,
|
||||||
|
identifier,
|
||||||
|
target_message_id="",
|
||||||
|
target_ts=int(reaction_payload.get("target_ts") or 0),
|
||||||
|
emoji=str(reaction_payload.get("emoji") or ""),
|
||||||
|
source_service="signal",
|
||||||
|
actor=(source_uuid or source_number or ""),
|
||||||
|
remove=bool(reaction_payload.get("remove")),
|
||||||
|
payload=reaction_payload.get("raw") or {},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.warning("signal raw reaction history apply failed: %s", exc)
|
||||||
|
try:
|
||||||
|
await self.ur.xmpp.client.apply_external_reaction(
|
||||||
|
identifier.user,
|
||||||
|
identifier,
|
||||||
|
source_service="signal",
|
||||||
|
emoji=str(reaction_payload.get("emoji") or ""),
|
||||||
|
remove=bool(reaction_payload.get("remove")),
|
||||||
|
upstream_message_id="",
|
||||||
|
upstream_ts=int(reaction_payload.get("target_ts") or 0),
|
||||||
|
actor=(source_uuid or source_number or ""),
|
||||||
|
payload=reaction_payload.get("raw") or {},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.warning("signal raw reaction relay to XMPP failed: %s", exc)
|
||||||
|
transport.update_runtime_state(
|
||||||
|
self.service,
|
||||||
|
last_inbound_ok_ts=int(time.time() * 1000),
|
||||||
|
last_inbound_exception_type="",
|
||||||
|
last_inbound_exception_message="",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
text = str(data_message.get("message") or "").strip()
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
ts_raw = (
|
||||||
|
envelope.get("timestamp")
|
||||||
|
or envelope.get("serverReceivedTimestamp")
|
||||||
|
or int(time.time() * 1000)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ts = int(ts_raw)
|
||||||
|
except Exception:
|
||||||
|
ts = int(time.time() * 1000)
|
||||||
|
source_message_id = str(
|
||||||
|
envelope.get("serverGuid")
|
||||||
|
or envelope.get("guid")
|
||||||
|
or envelope.get("timestamp")
|
||||||
|
or ts
|
||||||
|
).strip()
|
||||||
|
sender_key = source_uuid or source_number or (identifiers[0].identifier if identifiers else "")
|
||||||
|
source_chat_id = source_number or source_uuid or sender_key
|
||||||
|
reply_ref = reply_sync.extract_reply_ref(self.service, payload)
|
||||||
|
|
||||||
|
for identifier in identifiers:
|
||||||
|
session = await history.get_chat_session(identifier.user, identifier)
|
||||||
|
reply_target = await reply_sync.resolve_reply_target(
|
||||||
|
identifier.user,
|
||||||
|
session,
|
||||||
|
reply_ref,
|
||||||
|
)
|
||||||
|
exists = await sync_to_async(
|
||||||
|
lambda: Message.objects.filter(
|
||||||
|
user=identifier.user,
|
||||||
|
session=session,
|
||||||
|
source_service=self.service,
|
||||||
|
source_message_id=source_message_id,
|
||||||
|
).exists()
|
||||||
|
)()
|
||||||
|
if exists:
|
||||||
|
continue
|
||||||
|
local_message = await history.store_message(
|
||||||
|
session=session,
|
||||||
|
sender=sender_key,
|
||||||
|
text=text,
|
||||||
|
ts=ts,
|
||||||
|
outgoing=False,
|
||||||
|
source_service=self.service,
|
||||||
|
source_message_id=source_message_id,
|
||||||
|
source_chat_id=source_chat_id,
|
||||||
|
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={},
|
||||||
|
)
|
||||||
|
await self.ur.message_received(
|
||||||
|
self.service,
|
||||||
|
identifier=identifier,
|
||||||
|
text=text,
|
||||||
|
ts=ts,
|
||||||
|
payload=payload,
|
||||||
|
local_message=local_message,
|
||||||
|
)
|
||||||
|
transport.update_runtime_state(
|
||||||
|
self.service,
|
||||||
|
last_inbound_ok_ts=int(time.time() * 1000),
|
||||||
|
last_inbound_exception_type="",
|
||||||
|
last_inbound_exception_message="",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _raw_receive_loop(self):
|
||||||
|
signal_number = str(getattr(settings, "SIGNAL_NUMBER", "") or "").strip()
|
||||||
|
if not signal_number:
|
||||||
|
return
|
||||||
|
uri = f"ws://{SIGNAL_URL}/v1/receive/{signal_number}"
|
||||||
|
while not self._stopping:
|
||||||
|
try:
|
||||||
|
async with websockets.connect(uri, ping_interval=None) as websocket:
|
||||||
|
async for raw_message in websocket:
|
||||||
|
await self._process_raw_inbound_event(raw_message)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.warning("signal raw-receive loop error: %s", exc)
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
self.log.info("Signal client starting...")
|
self.log.info("Signal client starting...")
|
||||||
self.client._event_loop = self.loop
|
self.client._event_loop = self.loop
|
||||||
# Start background command processing loop
|
# Start background command processing loop
|
||||||
if not self._command_task or self._command_task.done():
|
if not self._command_task or self._command_task.done():
|
||||||
self._command_task = self.loop.create_task(self._command_loop())
|
self._command_task = self.loop.create_task(self._command_loop())
|
||||||
self.client.start()
|
if not self._raw_receive_task or self._raw_receive_task.done():
|
||||||
|
self._raw_receive_task = self.loop.create_task(self._raw_receive_loop())
|
||||||
|
# Use direct websocket receive loop as primary ingestion path.
|
||||||
|
# signalbot's internal receive consumer can compete for the same stream
|
||||||
|
# and starve inbound events in this deployment, so we keep it disabled.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import logging
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import orjson
|
import orjson
|
||||||
@@ -7,6 +8,8 @@ import requests
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def start_typing(uuid):
|
async def start_typing(uuid):
|
||||||
base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/")
|
base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/")
|
||||||
@@ -70,7 +73,7 @@ async def download_and_encode_base64(file_url, filename, content_type, session=N
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def send_message_raw(recipient_uuid, text=None, attachments=None):
|
async def send_message_raw(recipient_uuid, text=None, attachments=None, metadata=None):
|
||||||
"""
|
"""
|
||||||
Sends a message using the Signal REST API, ensuring attachment links are not included in the text body.
|
Sends a message using the Signal REST API, ensuring attachment links are not included in the text body.
|
||||||
|
|
||||||
@@ -90,6 +93,7 @@ async def send_message_raw(recipient_uuid, text=None, attachments=None):
|
|||||||
"number": settings.SIGNAL_NUMBER,
|
"number": settings.SIGNAL_NUMBER,
|
||||||
"base64_attachments": [],
|
"base64_attachments": [],
|
||||||
}
|
}
|
||||||
|
meta = dict(metadata or {})
|
||||||
|
|
||||||
async def _attachment_to_base64(attachment, session):
|
async def _attachment_to_base64(attachment, session):
|
||||||
row = dict(attachment or {})
|
row = dict(attachment or {})
|
||||||
@@ -132,14 +136,46 @@ async def send_message_raw(recipient_uuid, text=None, attachments=None):
|
|||||||
if text:
|
if text:
|
||||||
data["message"] = text
|
data["message"] = text
|
||||||
|
|
||||||
|
quote_timestamp = int(meta.get("quote_timestamp") or 0)
|
||||||
|
quote_author = str(meta.get("quote_author") or "").strip()
|
||||||
|
quote_text = str(meta.get("quote_text") or "").strip()
|
||||||
|
has_quote = quote_timestamp > 0 and bool(quote_author)
|
||||||
|
|
||||||
|
payloads = [dict(data)]
|
||||||
|
if has_quote:
|
||||||
|
flat_quote_payload = dict(data)
|
||||||
|
flat_quote_payload["quote_timestamp"] = int(quote_timestamp)
|
||||||
|
flat_quote_payload["quote_author"] = quote_author
|
||||||
|
if quote_text:
|
||||||
|
flat_quote_payload["quote_message"] = quote_text
|
||||||
|
|
||||||
|
nested_quote_payload = dict(data)
|
||||||
|
nested_quote_payload["quote"] = {
|
||||||
|
"id": int(quote_timestamp),
|
||||||
|
"author": quote_author,
|
||||||
|
}
|
||||||
|
if quote_text:
|
||||||
|
nested_quote_payload["quote"]["text"] = quote_text
|
||||||
|
|
||||||
|
payloads = [flat_quote_payload, nested_quote_payload, dict(data)]
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.post(url, json=data) as response:
|
for index, payload in enumerate(payloads):
|
||||||
|
async with session.post(url, json=payload) as response:
|
||||||
response_text = await response.text()
|
response_text = await response.text()
|
||||||
response_status = response.status
|
response_status = response.status
|
||||||
|
|
||||||
if response_status == status.HTTP_201_CREATED:
|
if response_status == status.HTTP_201_CREATED:
|
||||||
ts = orjson.loads(response_text).get("timestamp", None)
|
ts = orjson.loads(response_text).get("timestamp", None)
|
||||||
return ts if ts else False
|
return ts if ts else False
|
||||||
|
if index == len(payloads) - 1:
|
||||||
|
return False
|
||||||
|
if response_status not in {status.HTTP_400_BAD_REQUEST, status.HTTP_422_UNPROCESSABLE_ENTITY}:
|
||||||
|
return False
|
||||||
|
log.warning(
|
||||||
|
"signal send quote payload rejected (%s), trying fallback shape: %s",
|
||||||
|
response_status,
|
||||||
|
response_text[:200],
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import shutil
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
@@ -479,6 +481,46 @@ def _account_key(value: str) -> str:
|
|||||||
return raw
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _wipe_signal_cli_local_state() -> bool:
|
||||||
|
"""
|
||||||
|
Best-effort local signal-cli state reset for json-rpc deployments where
|
||||||
|
REST account delete endpoints are unavailable.
|
||||||
|
"""
|
||||||
|
config_roots = (
|
||||||
|
"/code/signal-cli-config",
|
||||||
|
"/signal-cli-config",
|
||||||
|
"/home/.local/share/signal-cli",
|
||||||
|
)
|
||||||
|
removed_any = False
|
||||||
|
for root in config_roots:
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entries = os.listdir(root)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for entry in entries:
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
# Keep runtime configuration scaffold; wipe account/pairing state.
|
||||||
|
if entry in {"jsonrpc2.yml", "jsonrpc.yml"}:
|
||||||
|
continue
|
||||||
|
path = os.path.join(root, entry)
|
||||||
|
if os.path.isdir(path):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(path)
|
||||||
|
removed_any = True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
removed_any = True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return removed_any
|
||||||
|
|
||||||
|
|
||||||
def unlink_account(service: str, account: str) -> bool:
|
def unlink_account(service: str, account: str) -> bool:
|
||||||
service_key = _service_key(service)
|
service_key = _service_key(service)
|
||||||
account_value = str(account or "").strip()
|
account_value = str(account or "").strip()
|
||||||
@@ -492,14 +534,18 @@ def unlink_account(service: str, account: str) -> bool:
|
|||||||
"/"
|
"/"
|
||||||
)
|
)
|
||||||
target = quote_plus(account_value)
|
target = quote_plus(account_value)
|
||||||
|
unlinked = False
|
||||||
for path in (f"/v1/accounts/{target}", f"/v1/account/{target}"):
|
for path in (f"/v1/accounts/{target}", f"/v1/account/{target}"):
|
||||||
try:
|
try:
|
||||||
response = requests.delete(f"{base}{path}", timeout=20)
|
response = requests.delete(f"{base}{path}", timeout=20)
|
||||||
if response.ok:
|
if response.ok:
|
||||||
return True
|
unlinked = True
|
||||||
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
return False
|
if unlinked:
|
||||||
|
return True
|
||||||
|
return _wipe_signal_cli_local_state()
|
||||||
|
|
||||||
if service_key in {"whatsapp", "instagram"}:
|
if service_key in {"whatsapp", "instagram"}:
|
||||||
state = get_runtime_state(service_key)
|
state = get_runtime_state(service_key)
|
||||||
@@ -715,8 +761,13 @@ async def send_message_raw(
|
|||||||
prepared_attachments = await prepare_outbound_attachments(
|
prepared_attachments = await prepare_outbound_attachments(
|
||||||
service_key, attachments or []
|
service_key, attachments or []
|
||||||
)
|
)
|
||||||
result = await signalapi.send_message_raw(recipient, text, prepared_attachments)
|
|
||||||
meta = dict(metadata or {})
|
meta = dict(metadata or {})
|
||||||
|
result = await signalapi.send_message_raw(
|
||||||
|
recipient,
|
||||||
|
text,
|
||||||
|
prepared_attachments,
|
||||||
|
metadata=meta,
|
||||||
|
)
|
||||||
xmpp_source_id = str(meta.get("xmpp_source_id") or "").strip()
|
xmpp_source_id = str(meta.get("xmpp_source_id") or "").strip()
|
||||||
if xmpp_source_id and result:
|
if xmpp_source_id and result:
|
||||||
from core.models import PersonIdentifier
|
from core.models import PersonIdentifier
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from django.conf import settings
|
|||||||
|
|
||||||
from core.commands.base import CommandContext, CommandHandler, CommandResult
|
from core.commands.base import CommandContext, CommandHandler, CommandResult
|
||||||
from core.commands.delivery import post_status_in_source, post_to_channel_binding
|
from core.commands.delivery import post_status_in_source, post_to_channel_binding
|
||||||
|
from core.commands.policies import BP_VARIANT_META, load_variant_policy
|
||||||
from core.messaging import ai as ai_runner
|
from core.messaging import ai as ai_runner
|
||||||
from core.messaging.text_export import plain_text_blob
|
from core.messaging.text_export import plain_text_blob
|
||||||
from core.messaging.utils import messages_to_string
|
from core.messaging.utils import messages_to_string
|
||||||
@@ -18,6 +19,7 @@ from core.models import (
|
|||||||
CommandAction,
|
CommandAction,
|
||||||
CommandChannelBinding,
|
CommandChannelBinding,
|
||||||
CommandRun,
|
CommandRun,
|
||||||
|
CommandVariantPolicy,
|
||||||
Message,
|
Message,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -91,6 +93,45 @@ def _clamp_transcript(transcript: str, max_chars: int) -> str:
|
|||||||
class BPCommandHandler(CommandHandler):
|
class BPCommandHandler(CommandHandler):
|
||||||
slug = "bp"
|
slug = "bp"
|
||||||
|
|
||||||
|
def _variant_key_for_text(self, text: str) -> str:
|
||||||
|
parsed = parse_bp_subcommand(text)
|
||||||
|
if parsed.command == "set":
|
||||||
|
return "bp_set"
|
||||||
|
if parsed.command == "set_range":
|
||||||
|
return "bp_set_range"
|
||||||
|
return "bp"
|
||||||
|
|
||||||
|
def _variant_display_name(self, variant_key: str) -> str:
|
||||||
|
meta = BP_VARIANT_META.get(str(variant_key or "").strip(), {})
|
||||||
|
return str(meta.get("name") or variant_key or "bp")
|
||||||
|
|
||||||
|
async def _effective_policy(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
profile,
|
||||||
|
variant_key: str,
|
||||||
|
action_types: set[str],
|
||||||
|
) -> dict:
|
||||||
|
policy = await sync_to_async(load_variant_policy)(profile, variant_key)
|
||||||
|
if isinstance(policy, CommandVariantPolicy):
|
||||||
|
return {
|
||||||
|
"enabled": bool(policy.enabled),
|
||||||
|
"generation_mode": str(policy.generation_mode or "verbatim"),
|
||||||
|
"send_plan_to_egress": bool(policy.send_plan_to_egress)
|
||||||
|
and ("post_result" in action_types),
|
||||||
|
"send_status_to_source": bool(policy.send_status_to_source),
|
||||||
|
"send_status_to_egress": bool(policy.send_status_to_egress),
|
||||||
|
"store_document": bool(getattr(policy, "store_document", True)),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"generation_mode": "ai" if variant_key == "bp" else "verbatim",
|
||||||
|
"send_plan_to_egress": "post_result" in action_types,
|
||||||
|
"send_status_to_source": str(profile.visibility_mode or "") == "status_in_source",
|
||||||
|
"send_status_to_egress": False,
|
||||||
|
"store_document": True,
|
||||||
|
}
|
||||||
|
|
||||||
async def _fanout(self, run: CommandRun, text: str) -> dict:
|
async def _fanout(self, run: CommandRun, text: str) -> dict:
|
||||||
profile = run.profile
|
profile = run.profile
|
||||||
trigger = await sync_to_async(
|
trigger = await sync_to_async(
|
||||||
@@ -124,6 +165,39 @@ class BPCommandHandler(CommandHandler):
|
|||||||
failed_bindings += 1
|
failed_bindings += 1
|
||||||
return {"sent_bindings": sent_bindings, "failed_bindings": failed_bindings}
|
return {"sent_bindings": sent_bindings, "failed_bindings": failed_bindings}
|
||||||
|
|
||||||
|
async def _fanout_status(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:
|
||||||
|
ok = await post_to_channel_binding(
|
||||||
|
trigger_message=trigger,
|
||||||
|
binding_service=binding.service,
|
||||||
|
binding_channel_identifier=binding.channel_identifier,
|
||||||
|
text=text,
|
||||||
|
origin_tag=f"bp-status-egress:{run.id}",
|
||||||
|
command_slug=self.slug,
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
sent_bindings += 1
|
||||||
|
else:
|
||||||
|
failed_bindings += 1
|
||||||
|
return {"sent_bindings": sent_bindings, "failed_bindings": failed_bindings}
|
||||||
|
|
||||||
async def _load_window(self, trigger: Message, anchor: Message) -> list[Message]:
|
async def _load_window(self, trigger: Message, anchor: Message) -> list[Message]:
|
||||||
return await sync_to_async(list)(
|
return await sync_to_async(list)(
|
||||||
Message.objects.filter(
|
Message.objects.filter(
|
||||||
@@ -188,7 +262,8 @@ class BPCommandHandler(CommandHandler):
|
|||||||
trigger: Message,
|
trigger: Message,
|
||||||
run: CommandRun,
|
run: CommandRun,
|
||||||
profile,
|
profile,
|
||||||
action_types: set[str],
|
policy: dict,
|
||||||
|
variant_key: str,
|
||||||
parsed: BPParsedCommand,
|
parsed: BPParsedCommand,
|
||||||
) -> CommandResult:
|
) -> CommandResult:
|
||||||
mode = str(parsed.command or "")
|
mode = str(parsed.command or "")
|
||||||
@@ -202,13 +277,53 @@ class BPCommandHandler(CommandHandler):
|
|||||||
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
||||||
return CommandResult(ok=False, status="failed", error=run.error)
|
return CommandResult(ok=False, status="failed", error=run.error)
|
||||||
rows = await self._load_window(trigger, anchor)
|
rows = await self._load_window(trigger, anchor)
|
||||||
content = plain_text_blob(rows)
|
deterministic_content = plain_text_blob(rows)
|
||||||
if not content.strip():
|
if not deterministic_content.strip():
|
||||||
run.status = "failed"
|
run.status = "failed"
|
||||||
run.error = "bp_set_range_empty_content"
|
run.error = "bp_set_range_empty_content"
|
||||||
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
||||||
return CommandResult(ok=False, status="failed", error=run.error)
|
return CommandResult(ok=False, status="failed", error=run.error)
|
||||||
|
if str(policy.get("generation_mode") or "verbatim") == "ai":
|
||||||
|
ai_obj = await sync_to_async(lambda: AI.objects.filter(user=trigger.user).first())()
|
||||||
|
if ai_obj is None:
|
||||||
|
run.status = "failed"
|
||||||
|
run.error = "ai_not_configured"
|
||||||
|
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
||||||
|
return CommandResult(ok=False, status="failed", error=run.error)
|
||||||
|
prompt = [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"Transform source chat text into a structured business plan in markdown. "
|
||||||
|
"Do not reference any user template."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{"role": "user", "content": deterministic_content},
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
content = str(
|
||||||
|
await ai_runner.run_prompt(
|
||||||
|
prompt,
|
||||||
|
ai_obj,
|
||||||
|
operation="command_bp_set_range_extract",
|
||||||
|
)
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
except Exception as exc:
|
||||||
|
run.status = "failed"
|
||||||
|
run.error = f"bp_ai_failed:{exc}"
|
||||||
|
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
||||||
|
return CommandResult(ok=False, status="failed", error=run.error)
|
||||||
|
if not content:
|
||||||
|
run.status = "failed"
|
||||||
|
run.error = "empty_ai_response"
|
||||||
|
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
||||||
|
return CommandResult(ok=False, status="failed", error=run.error)
|
||||||
|
else:
|
||||||
|
content = deterministic_content
|
||||||
annotation = self._annotation("set_range", len(rows))
|
annotation = self._annotation("set_range", len(rows))
|
||||||
|
doc = None
|
||||||
|
if bool(policy.get("store_document", True)):
|
||||||
doc = await self._persist_document(
|
doc = await self._persist_document(
|
||||||
run=run,
|
run=run,
|
||||||
trigger=trigger,
|
trigger=trigger,
|
||||||
@@ -244,7 +359,47 @@ class BPCommandHandler(CommandHandler):
|
|||||||
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
||||||
return CommandResult(ok=False, status="failed", error=run.error)
|
return CommandResult(ok=False, status="failed", error=run.error)
|
||||||
|
|
||||||
|
if str(policy.get("generation_mode") or "verbatim") == "ai":
|
||||||
|
ai_obj = await sync_to_async(lambda: AI.objects.filter(user=trigger.user).first())()
|
||||||
|
if ai_obj is None:
|
||||||
|
run.status = "failed"
|
||||||
|
run.error = "ai_not_configured"
|
||||||
|
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
||||||
|
return CommandResult(ok=False, status="failed", error=run.error)
|
||||||
|
prompt = [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"Transform source chat text into a structured business plan in markdown. "
|
||||||
|
"Do not reference any user template."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{"role": "user", "content": content},
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
ai_content = str(
|
||||||
|
await ai_runner.run_prompt(
|
||||||
|
prompt,
|
||||||
|
ai_obj,
|
||||||
|
operation="command_bp_set_extract",
|
||||||
|
)
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
except Exception as exc:
|
||||||
|
run.status = "failed"
|
||||||
|
run.error = f"bp_ai_failed:{exc}"
|
||||||
|
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
||||||
|
return CommandResult(ok=False, status="failed", error=run.error)
|
||||||
|
if not ai_content:
|
||||||
|
run.status = "failed"
|
||||||
|
run.error = "empty_ai_response"
|
||||||
|
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
||||||
|
return CommandResult(ok=False, status="failed", error=run.error)
|
||||||
|
content = ai_content
|
||||||
|
|
||||||
annotation = self._annotation("set", 1 if not has_addendum else 2, has_addendum)
|
annotation = self._annotation("set", 1 if not has_addendum else 2, has_addendum)
|
||||||
|
doc = None
|
||||||
|
if bool(policy.get("store_document", True)):
|
||||||
doc = await self._persist_document(
|
doc = await self._persist_document(
|
||||||
run=run,
|
run=run,
|
||||||
trigger=trigger,
|
trigger=trigger,
|
||||||
@@ -262,31 +417,38 @@ class BPCommandHandler(CommandHandler):
|
|||||||
return CommandResult(ok=False, status="failed", error=run.error)
|
return CommandResult(ok=False, status="failed", error=run.error)
|
||||||
|
|
||||||
fanout_stats = {"sent_bindings": 0, "failed_bindings": 0}
|
fanout_stats = {"sent_bindings": 0, "failed_bindings": 0}
|
||||||
if "post_result" in action_types:
|
if bool(policy.get("send_plan_to_egress")):
|
||||||
fanout_body = f"{doc.content_markdown}\n\n{doc.structured_payload.get('annotation', '')}".strip()
|
fanout_body = f"{content}\n\n{annotation}".strip()
|
||||||
fanout_stats = await self._fanout(run, fanout_body)
|
fanout_stats = await self._fanout(run, fanout_body)
|
||||||
|
|
||||||
if "status_in_source" == profile.visibility_mode:
|
|
||||||
status_text = (
|
|
||||||
f"[bp] {doc.structured_payload.get('annotation', '').strip()} "
|
|
||||||
f"Saved as {doc.title}."
|
|
||||||
).strip()
|
|
||||||
sent_count = int(fanout_stats.get("sent_bindings") or 0)
|
sent_count = int(fanout_stats.get("sent_bindings") or 0)
|
||||||
failed_count = int(fanout_stats.get("failed_bindings") or 0)
|
failed_count = int(fanout_stats.get("failed_bindings") or 0)
|
||||||
if sent_count or failed_count:
|
status_text = (
|
||||||
status_text += f" · fanout sent:{sent_count}"
|
f"[bp:{self._variant_display_name(variant_key)}:{policy.get('generation_mode')}] "
|
||||||
|
f"{annotation.strip()} "
|
||||||
|
f"{'Saved as ' + doc.title + ' · ' if doc else 'Not saved (store_document disabled) · '}"
|
||||||
|
f"fanout sent:{sent_count}"
|
||||||
|
).strip()
|
||||||
if failed_count:
|
if failed_count:
|
||||||
status_text += f" failed:{failed_count}"
|
status_text += f" failed:{failed_count}"
|
||||||
|
|
||||||
|
if bool(policy.get("send_status_to_source")):
|
||||||
await post_status_in_source(
|
await post_status_in_source(
|
||||||
trigger_message=trigger,
|
trigger_message=trigger,
|
||||||
text=status_text,
|
text=status_text,
|
||||||
origin_tag=f"bp-status:{trigger.id}",
|
origin_tag=f"bp-status:{trigger.id}",
|
||||||
)
|
)
|
||||||
|
if bool(policy.get("send_status_to_egress")):
|
||||||
|
await self._fanout_status(run, status_text)
|
||||||
|
|
||||||
run.status = "ok"
|
run.status = "ok"
|
||||||
run.error = ""
|
run.error = ""
|
||||||
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
||||||
return CommandResult(ok=True, status="ok", payload={"document_id": str(doc.id)})
|
return CommandResult(
|
||||||
|
ok=True,
|
||||||
|
status="ok",
|
||||||
|
payload={"document_id": str(doc.id) if doc else ""},
|
||||||
|
)
|
||||||
|
|
||||||
async def _execute_legacy_ai(
|
async def _execute_legacy_ai(
|
||||||
self,
|
self,
|
||||||
@@ -294,8 +456,8 @@ class BPCommandHandler(CommandHandler):
|
|||||||
trigger: Message,
|
trigger: Message,
|
||||||
run: CommandRun,
|
run: CommandRun,
|
||||||
profile,
|
profile,
|
||||||
action_types: set[str],
|
policy: dict,
|
||||||
ctx: CommandContext,
|
variant_key: str,
|
||||||
) -> CommandResult:
|
) -> CommandResult:
|
||||||
if trigger.reply_to_id is None:
|
if trigger.reply_to_id is None:
|
||||||
run.status = "failed"
|
run.status = "failed"
|
||||||
@@ -322,6 +484,15 @@ class BPCommandHandler(CommandHandler):
|
|||||||
template_text = profile.template_text or default_template
|
template_text = profile.template_text or default_template
|
||||||
max_template_chars = int(getattr(settings, "BP_MAX_TEMPLATE_CHARS", 5000) or 5000)
|
max_template_chars = int(getattr(settings, "BP_MAX_TEMPLATE_CHARS", 5000) or 5000)
|
||||||
template_text = str(template_text or "")[:max_template_chars]
|
template_text = str(template_text or "")[:max_template_chars]
|
||||||
|
generation_mode = str(policy.get("generation_mode") or "ai")
|
||||||
|
if generation_mode == "verbatim":
|
||||||
|
summary = plain_text_blob(rows)
|
||||||
|
if not summary.strip():
|
||||||
|
run.status = "failed"
|
||||||
|
run.error = "bp_verbatim_empty_content"
|
||||||
|
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
||||||
|
return CommandResult(ok=False, status="failed", error=run.error)
|
||||||
|
else:
|
||||||
ai_obj = await sync_to_async(lambda: AI.objects.filter(user=trigger.user).first())()
|
ai_obj = await sync_to_async(lambda: AI.objects.filter(user=trigger.user).first())()
|
||||||
if ai_obj is None:
|
if ai_obj is None:
|
||||||
run.status = "failed"
|
run.status = "failed"
|
||||||
@@ -352,6 +523,8 @@ class BPCommandHandler(CommandHandler):
|
|||||||
return CommandResult(ok=False, status="failed", error=run.error)
|
return CommandResult(ok=False, status="failed", error=run.error)
|
||||||
|
|
||||||
annotation = self._annotation("legacy", len(rows))
|
annotation = self._annotation("legacy", len(rows))
|
||||||
|
document = None
|
||||||
|
if bool(policy.get("store_document", True)):
|
||||||
document = await self._persist_document(
|
document = await self._persist_document(
|
||||||
run=run,
|
run=run,
|
||||||
trigger=trigger,
|
trigger=trigger,
|
||||||
@@ -364,27 +537,37 @@ class BPCommandHandler(CommandHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
fanout_stats = {"sent_bindings": 0, "failed_bindings": 0}
|
fanout_stats = {"sent_bindings": 0, "failed_bindings": 0}
|
||||||
if "post_result" in action_types:
|
if bool(policy.get("send_plan_to_egress")):
|
||||||
fanout_stats = await self._fanout(run, summary)
|
fanout_stats = await self._fanout(run, summary)
|
||||||
|
|
||||||
if "status_in_source" == profile.visibility_mode:
|
|
||||||
status_text = f"[bp] Generated business plan: {document.title}"
|
|
||||||
sent_count = int(fanout_stats.get("sent_bindings") or 0)
|
sent_count = int(fanout_stats.get("sent_bindings") or 0)
|
||||||
failed_count = int(fanout_stats.get("failed_bindings") or 0)
|
failed_count = int(fanout_stats.get("failed_bindings") or 0)
|
||||||
if sent_count or failed_count:
|
status_text = (
|
||||||
status_text += f" · fanout sent:{sent_count}"
|
f"[bp:{self._variant_display_name(variant_key)}:{generation_mode}] "
|
||||||
|
f"Generated business plan: "
|
||||||
|
f"{document.title if document else 'not saved (store_document disabled)'} "
|
||||||
|
f"· fanout sent:{sent_count}"
|
||||||
|
)
|
||||||
if failed_count:
|
if failed_count:
|
||||||
status_text += f" failed:{failed_count}"
|
status_text += f" failed:{failed_count}"
|
||||||
|
|
||||||
|
if bool(policy.get("send_status_to_source")):
|
||||||
await post_status_in_source(
|
await post_status_in_source(
|
||||||
trigger_message=trigger,
|
trigger_message=trigger,
|
||||||
text=status_text,
|
text=status_text,
|
||||||
origin_tag=f"bp-status:{trigger.id}",
|
origin_tag=f"bp-status:{trigger.id}",
|
||||||
)
|
)
|
||||||
|
if bool(policy.get("send_status_to_egress")):
|
||||||
|
await self._fanout_status(run, status_text)
|
||||||
|
|
||||||
run.status = "ok"
|
run.status = "ok"
|
||||||
run.error = ""
|
run.error = ""
|
||||||
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
||||||
return CommandResult(ok=True, status="ok", payload={"document_id": str(document.id)})
|
return CommandResult(
|
||||||
|
ok=True,
|
||||||
|
status="ok",
|
||||||
|
payload={"document_id": str(document.id) if document else ""},
|
||||||
|
)
|
||||||
|
|
||||||
async def execute(self, ctx: CommandContext) -> CommandResult:
|
async def execute(self, ctx: CommandContext) -> CommandResult:
|
||||||
trigger = await sync_to_async(
|
trigger = await sync_to_async(
|
||||||
@@ -418,13 +601,26 @@ class BPCommandHandler(CommandHandler):
|
|||||||
run.error = ""
|
run.error = ""
|
||||||
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
||||||
|
|
||||||
|
variant_key = self._variant_key_for_text(ctx.message_text)
|
||||||
|
policy = await self._effective_policy(
|
||||||
|
profile=profile,
|
||||||
|
variant_key=variant_key,
|
||||||
|
action_types=action_types,
|
||||||
|
)
|
||||||
|
if not bool(policy.get("enabled")):
|
||||||
|
run.status = "skipped"
|
||||||
|
run.error = f"variant_disabled:{variant_key}"
|
||||||
|
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
|
||||||
|
return CommandResult(ok=False, status="skipped", error=run.error)
|
||||||
|
|
||||||
parsed = parse_bp_subcommand(ctx.message_text)
|
parsed = parse_bp_subcommand(ctx.message_text)
|
||||||
if parsed.command and bool(getattr(settings, "BP_SUBCOMMANDS_V1", True)):
|
if parsed.command and bool(getattr(settings, "BP_SUBCOMMANDS_V1", True)):
|
||||||
return await self._execute_set_or_range(
|
return await self._execute_set_or_range(
|
||||||
trigger=trigger,
|
trigger=trigger,
|
||||||
run=run,
|
run=run,
|
||||||
profile=profile,
|
profile=profile,
|
||||||
action_types=action_types,
|
policy=policy,
|
||||||
|
variant_key=variant_key,
|
||||||
parsed=parsed,
|
parsed=parsed,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -432,6 +628,6 @@ class BPCommandHandler(CommandHandler):
|
|||||||
trigger=trigger,
|
trigger=trigger,
|
||||||
run=run,
|
run=run,
|
||||||
profile=profile,
|
profile=profile,
|
||||||
action_types=action_types,
|
policy=policy,
|
||||||
ctx=ctx,
|
variant_key=variant_key,
|
||||||
)
|
)
|
||||||
|
|||||||
106
core/commands/policies.py
Normal file
106
core/commands/policies.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from core.models import CommandAction, CommandProfile, CommandVariantPolicy
|
||||||
|
|
||||||
|
BP_VARIANT_KEYS = ("bp", "bp_set", "bp_set_range")
|
||||||
|
BP_VARIANT_META = {
|
||||||
|
"bp": {
|
||||||
|
"name": "bp",
|
||||||
|
"trigger_token": "#bp#",
|
||||||
|
"template_supported": True,
|
||||||
|
"position": 0,
|
||||||
|
},
|
||||||
|
"bp_set": {
|
||||||
|
"name": "bp set",
|
||||||
|
"trigger_token": "#bp set#",
|
||||||
|
"template_supported": False,
|
||||||
|
"position": 1,
|
||||||
|
},
|
||||||
|
"bp_set_range": {
|
||||||
|
"name": "bp set range",
|
||||||
|
"trigger_token": "#bp set range#",
|
||||||
|
"template_supported": False,
|
||||||
|
"position": 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_defaults(profile: CommandProfile, post_result_enabled: bool) -> dict:
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"generation_mode": "ai",
|
||||||
|
"send_plan_to_egress": bool(post_result_enabled),
|
||||||
|
"send_status_to_source": str(profile.visibility_mode or "") == "status_in_source",
|
||||||
|
"send_status_to_egress": False,
|
||||||
|
"store_document": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bp_defaults(
|
||||||
|
profile: CommandProfile,
|
||||||
|
variant_key: str,
|
||||||
|
post_result_enabled: bool,
|
||||||
|
) -> dict:
|
||||||
|
defaults = _legacy_defaults(profile, post_result_enabled)
|
||||||
|
if variant_key in {"bp_set", "bp_set_range"}:
|
||||||
|
defaults["generation_mode"] = "verbatim"
|
||||||
|
else:
|
||||||
|
defaults["generation_mode"] = "ai"
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_variant_policies_for_profile(
|
||||||
|
profile: CommandProfile,
|
||||||
|
*,
|
||||||
|
action_rows: Iterable[CommandAction] | None = None,
|
||||||
|
) -> dict[str, CommandVariantPolicy]:
|
||||||
|
actions = list(action_rows) if action_rows is not None else list(profile.actions.all())
|
||||||
|
post_result_enabled = any(
|
||||||
|
row.action_type == "post_result" and bool(row.enabled) for row in actions
|
||||||
|
)
|
||||||
|
result: dict[str, CommandVariantPolicy] = {}
|
||||||
|
|
||||||
|
if str(profile.slug or "").strip() == "bp":
|
||||||
|
for key in BP_VARIANT_KEYS:
|
||||||
|
meta = BP_VARIANT_META.get(key, {})
|
||||||
|
defaults = _bp_defaults(profile, key, post_result_enabled)
|
||||||
|
policy, _ = CommandVariantPolicy.objects.get_or_create(
|
||||||
|
profile=profile,
|
||||||
|
variant_key=key,
|
||||||
|
defaults={
|
||||||
|
**defaults,
|
||||||
|
"position": int(meta.get("position") or 0),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result[key] = policy
|
||||||
|
else:
|
||||||
|
defaults = _legacy_defaults(profile, post_result_enabled)
|
||||||
|
policy, _ = CommandVariantPolicy.objects.get_or_create(
|
||||||
|
profile=profile,
|
||||||
|
variant_key="default",
|
||||||
|
defaults={
|
||||||
|
**defaults,
|
||||||
|
"generation_mode": "verbatim",
|
||||||
|
"position": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result["default"] = policy
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def load_variant_policy(profile: CommandProfile, variant_key: str) -> CommandVariantPolicy | None:
|
||||||
|
key = str(variant_key or "").strip()
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
|
policy = (
|
||||||
|
profile.variant_policies.filter(variant_key=key)
|
||||||
|
.order_by("position", "id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if policy is not None:
|
||||||
|
return policy
|
||||||
|
ensured = ensure_variant_policies_for_profile(profile)
|
||||||
|
return ensured.get(key)
|
||||||
@@ -56,17 +56,82 @@ def _find_origin_tag(value: Any, depth: int = 0) -> str:
|
|||||||
|
|
||||||
def _extract_signal_reply(raw_payload: dict[str, Any]) -> dict[str, str]:
|
def _extract_signal_reply(raw_payload: dict[str, Any]) -> dict[str, str]:
|
||||||
envelope = _as_dict((raw_payload or {}).get("envelope"))
|
envelope = _as_dict((raw_payload or {}).get("envelope"))
|
||||||
data_message = _as_dict(
|
sync_message = _as_dict(envelope.get("syncMessage"))
|
||||||
envelope.get("dataMessage")
|
sent_message = _as_dict(sync_message.get("sentMessage"))
|
||||||
or envelope.get("syncMessage", {}).get("sentMessage", {}).get("message")
|
data_candidates = [
|
||||||
|
_as_dict(envelope.get("dataMessage")),
|
||||||
|
_as_dict(sent_message.get("message")),
|
||||||
|
_as_dict(sent_message),
|
||||||
|
_as_dict((raw_payload or {}).get("dataMessage")),
|
||||||
|
_as_dict(raw_payload),
|
||||||
|
]
|
||||||
|
quote_key_candidates = (
|
||||||
|
"id",
|
||||||
|
"targetSentTimestamp",
|
||||||
|
"targetTimestamp",
|
||||||
|
"quotedMessageId",
|
||||||
|
"quoted_message_id",
|
||||||
|
"quotedMessageID",
|
||||||
|
"messageId",
|
||||||
|
"message_id",
|
||||||
|
"timestamp",
|
||||||
)
|
)
|
||||||
quote = _as_dict(data_message.get("quote"))
|
quote_author_candidates = (
|
||||||
quote_id = _clean(quote.get("id"))
|
"author",
|
||||||
|
"authorUuid",
|
||||||
|
"authorAci",
|
||||||
|
"authorNumber",
|
||||||
|
"source",
|
||||||
|
"sourceNumber",
|
||||||
|
"sourceUuid",
|
||||||
|
)
|
||||||
|
quote_candidates: list[dict[str, Any]] = []
|
||||||
|
for data_message in data_candidates:
|
||||||
|
if not data_message:
|
||||||
|
continue
|
||||||
|
direct_quote = _as_dict(data_message.get("quote") or data_message.get("Quote"))
|
||||||
|
if direct_quote:
|
||||||
|
quote_candidates.append(direct_quote)
|
||||||
|
|
||||||
|
stack = [data_message]
|
||||||
|
while stack:
|
||||||
|
current = stack.pop()
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
continue
|
||||||
|
for key, value in current.items():
|
||||||
|
if isinstance(value, dict):
|
||||||
|
key_text = str(key or "").strip().lower()
|
||||||
|
if "quote" in key_text or "reply" in key_text:
|
||||||
|
quote_candidates.append(_as_dict(value))
|
||||||
|
stack.append(value)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for item in value:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
stack.append(item)
|
||||||
|
|
||||||
|
for quote in quote_candidates:
|
||||||
|
quote_id = ""
|
||||||
|
for key in quote_key_candidates:
|
||||||
|
quote_id = _clean(quote.get(key))
|
||||||
if quote_id:
|
if quote_id:
|
||||||
|
break
|
||||||
|
if not quote_id:
|
||||||
|
nested = _as_dict(quote.get("id"))
|
||||||
|
if nested:
|
||||||
|
for key in quote_key_candidates:
|
||||||
|
quote_id = _clean(nested.get(key))
|
||||||
|
if quote_id:
|
||||||
|
break
|
||||||
|
if quote_id:
|
||||||
|
reply_chat_id = ""
|
||||||
|
for key in quote_author_candidates:
|
||||||
|
reply_chat_id = _clean(quote.get(key))
|
||||||
|
if reply_chat_id:
|
||||||
|
break
|
||||||
return {
|
return {
|
||||||
"reply_source_message_id": quote_id,
|
"reply_source_message_id": quote_id,
|
||||||
"reply_source_service": "signal",
|
"reply_source_service": "signal",
|
||||||
"reply_source_chat_id": "",
|
"reply_source_chat_id": reply_chat_id,
|
||||||
}
|
}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|||||||
107
core/migrations/0031_commandvariantpolicy.py
Normal file
107
core/migrations/0031_commandvariantpolicy.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-03-02 14:17
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_variant_policies(apps, schema_editor):
|
||||||
|
CommandProfile = apps.get_model("core", "CommandProfile")
|
||||||
|
CommandAction = apps.get_model("core", "CommandAction")
|
||||||
|
CommandVariantPolicy = apps.get_model("core", "CommandVariantPolicy")
|
||||||
|
|
||||||
|
for profile in CommandProfile.objects.all().iterator():
|
||||||
|
actions = list(CommandAction.objects.filter(profile=profile))
|
||||||
|
post_result_enabled = any(
|
||||||
|
str(getattr(row, "action_type", "")) == "post_result"
|
||||||
|
and bool(getattr(row, "enabled", False))
|
||||||
|
for row in actions
|
||||||
|
)
|
||||||
|
send_status_to_source = (
|
||||||
|
str(getattr(profile, "visibility_mode", "") or "") == "status_in_source"
|
||||||
|
)
|
||||||
|
if str(getattr(profile, "slug", "") or "") == "bp":
|
||||||
|
rows = (
|
||||||
|
("bp", "ai", 0),
|
||||||
|
("bp_set", "verbatim", 1),
|
||||||
|
("bp_set_range", "verbatim", 2),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = (("default", "verbatim", 0),)
|
||||||
|
|
||||||
|
for key, generation_mode, position in rows:
|
||||||
|
CommandVariantPolicy.objects.get_or_create(
|
||||||
|
profile=profile,
|
||||||
|
variant_key=key,
|
||||||
|
defaults={
|
||||||
|
"enabled": True,
|
||||||
|
"generation_mode": generation_mode,
|
||||||
|
"send_plan_to_egress": bool(post_result_enabled),
|
||||||
|
"send_status_to_source": bool(send_status_to_source),
|
||||||
|
"send_status_to_egress": False,
|
||||||
|
"position": int(position),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0030_chattasksource_settings"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="CommandVariantPolicy",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("variant_key", models.CharField(default="default", max_length=64)),
|
||||||
|
("enabled", models.BooleanField(default=True)),
|
||||||
|
(
|
||||||
|
"generation_mode",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("ai", "AI"), ("verbatim", "Verbatim")],
|
||||||
|
default="verbatim",
|
||||||
|
max_length=32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("send_plan_to_egress", models.BooleanField(default=True)),
|
||||||
|
("send_status_to_source", models.BooleanField(default=True)),
|
||||||
|
("send_status_to_egress", models.BooleanField(default=False)),
|
||||||
|
("position", models.PositiveIntegerField(default=0)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"profile",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="variant_policies",
|
||||||
|
to="core.commandprofile",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["position", "id"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(
|
||||||
|
fields=["profile", "enabled", "variant_key"],
|
||||||
|
name="core_comman_profile_7913f5_idx",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
"constraints": [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=("profile", "variant_key"),
|
||||||
|
name="unique_command_variant_policy_per_profile",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.RunPython(_backfill_variant_policies, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
18
core/migrations/0032_commandvariantpolicy_store_document.py
Normal file
18
core/migrations/0032_commandvariantpolicy_store_document.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-03-02 17:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0031_commandvariantpolicy'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='commandvariantpolicy',
|
||||||
|
name='store_document',
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1725,6 +1725,45 @@ class CommandChannelBinding(models.Model):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CommandVariantPolicy(models.Model):
|
||||||
|
GENERATION_MODE_CHOICES = (
|
||||||
|
("ai", "AI"),
|
||||||
|
("verbatim", "Verbatim"),
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = models.ForeignKey(
|
||||||
|
CommandProfile,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="variant_policies",
|
||||||
|
)
|
||||||
|
variant_key = models.CharField(max_length=64, default="default")
|
||||||
|
enabled = models.BooleanField(default=True)
|
||||||
|
generation_mode = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
choices=GENERATION_MODE_CHOICES,
|
||||||
|
default="verbatim",
|
||||||
|
)
|
||||||
|
send_plan_to_egress = models.BooleanField(default=True)
|
||||||
|
send_status_to_source = models.BooleanField(default=True)
|
||||||
|
send_status_to_egress = models.BooleanField(default=False)
|
||||||
|
store_document = models.BooleanField(default=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"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["profile", "variant_key"],
|
||||||
|
name="unique_command_variant_policy_per_profile",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["profile", "enabled", "variant_key"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class CommandAction(models.Model):
|
class CommandAction(models.Model):
|
||||||
ACTION_CHOICES = (
|
ACTION_CHOICES = (
|
||||||
("extract_bp", "Extract Business Plan"),
|
("extract_bp", "Extract Business Plan"),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from core.clients.transport import send_message_raw
|
|||||||
from core.messaging import ai as ai_runner
|
from core.messaging import ai as ai_runner
|
||||||
from core.models import (
|
from core.models import (
|
||||||
AI,
|
AI,
|
||||||
|
Chat,
|
||||||
ChatTaskSource,
|
ChatTaskSource,
|
||||||
DerivedTask,
|
DerivedTask,
|
||||||
DerivedTaskEvent,
|
DerivedTaskEvent,
|
||||||
@@ -30,18 +31,43 @@ def _channel_variants(service: str, channel: str) -> list[str]:
|
|||||||
if not value:
|
if not value:
|
||||||
return []
|
return []
|
||||||
variants = [value]
|
variants = [value]
|
||||||
if str(service or "").strip().lower() == "whatsapp":
|
service_key = str(service or "").strip().lower()
|
||||||
|
if service_key == "whatsapp":
|
||||||
bare = value.split("@", 1)[0].strip()
|
bare = value.split("@", 1)[0].strip()
|
||||||
if bare and bare not in variants:
|
if bare and bare not in variants:
|
||||||
variants.append(bare)
|
variants.append(bare)
|
||||||
|
direct = f"{bare}@s.whatsapp.net" if bare else ""
|
||||||
|
if direct and direct not in variants:
|
||||||
|
variants.append(direct)
|
||||||
group = f"{bare}@g.us" if bare else ""
|
group = f"{bare}@g.us" if bare else ""
|
||||||
if group and group not in variants:
|
if group and group not in variants:
|
||||||
variants.append(group)
|
variants.append(group)
|
||||||
|
if service_key == "signal":
|
||||||
|
digits = re.sub(r"[^0-9]", "", value)
|
||||||
|
if digits and digits not in variants:
|
||||||
|
variants.append(digits)
|
||||||
|
if digits:
|
||||||
|
plus = f"+{digits}"
|
||||||
|
if plus not in variants:
|
||||||
|
variants.append(plus)
|
||||||
return variants
|
return variants
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_source_mappings(message: Message) -> list[ChatTaskSource]:
|
async def _resolve_source_mappings(message: Message) -> list[ChatTaskSource]:
|
||||||
variants = _channel_variants(message.source_service or "", message.source_chat_id or "")
|
variants = _channel_variants(message.source_service or "", message.source_chat_id or "")
|
||||||
|
if str(message.source_service or "").strip().lower() == "signal":
|
||||||
|
signal_value = str(message.source_chat_id or "").strip()
|
||||||
|
if signal_value:
|
||||||
|
companions = await sync_to_async(list)(
|
||||||
|
Chat.objects.filter(source_uuid=signal_value).values_list("source_number", flat=True)
|
||||||
|
)
|
||||||
|
companions += await sync_to_async(list)(
|
||||||
|
Chat.objects.filter(source_number=signal_value).values_list("source_uuid", flat=True)
|
||||||
|
)
|
||||||
|
for candidate in companions:
|
||||||
|
for expanded in _channel_variants("signal", str(candidate or "").strip()):
|
||||||
|
if expanded and expanded not in variants:
|
||||||
|
variants.append(expanded)
|
||||||
if not variants:
|
if not variants:
|
||||||
return []
|
return []
|
||||||
return await sync_to_async(list)(
|
return await sync_to_async(list)(
|
||||||
|
|||||||
@@ -398,6 +398,9 @@
|
|||||||
<a class="navbar-item" href="{% url 'command_routing' %}">
|
<a class="navbar-item" href="{% url 'command_routing' %}">
|
||||||
Command Routing
|
Command Routing
|
||||||
</a>
|
</a>
|
||||||
|
<a class="navbar-item" href="{% url 'tasks_settings' %}">
|
||||||
|
Task Settings
|
||||||
|
</a>
|
||||||
<a class="navbar-item" href="{% url 'translation_settings' %}">
|
<a class="navbar-item" href="{% url 'translation_settings' %}">
|
||||||
Translation
|
Translation
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -4,30 +4,44 @@
|
|||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="title is-4">Command Routing</h1>
|
<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>
|
<p class="subtitle is-6">Configure commands, channel bindings, and per-command delivery in a predictable way.</p>
|
||||||
|
{% if scope_service and scope_identifier %}
|
||||||
|
<article class="notification is-info is-light">
|
||||||
|
Scoped to this chat only: <strong>{{ scope_service }}</strong> · <code>{{ scope_identifier }}</code>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<article class="box">
|
<article class="box">
|
||||||
<h2 class="title is-6">Create Command Profile</h2>
|
<h2 class="title is-6">Create Command Profile</h2>
|
||||||
<p class="help">Create reusable command behavior. Example: <code>#bp#</code> reply command for business-plan extraction.</p>
|
<p class="help">Create reusable command behavior. <code>bp set</code> and <code>bp set range</code> are fixed bp subcommands and will appear automatically.</p>
|
||||||
<form method="post" aria-label="Create command profile">
|
<form method="post" aria-label="Create command profile">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="profile_create">
|
<input type="hidden" name="action" value="profile_create">
|
||||||
<div class="columns">
|
{% if scope_service and scope_identifier %}
|
||||||
<div class="column">
|
<input type="hidden" name="service" value="{{ scope_service }}">
|
||||||
<label class="label is-size-7" for="create_slug">Slug</label>
|
<input type="hidden" name="identifier" value="{{ scope_identifier }}">
|
||||||
<input id="create_slug" class="input is-small" name="slug" placeholder="slug (bp)" value="bp" aria-describedby="create_slug_help">
|
{% endif %}
|
||||||
<p id="create_slug_help" class="help">Stable command id, e.g. <code>bp</code>.</p>
|
<div class="columns is-multiline">
|
||||||
|
<div class="column is-4">
|
||||||
|
<label class="label is-size-7" for="create_command_slug">Command</label>
|
||||||
|
<div class="select is-small is-fullwidth">
|
||||||
|
<select id="create_command_slug" name="command_slug">
|
||||||
|
{% for value, label in command_choices %}
|
||||||
|
<option value="{{ value }}">{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
</div>
|
||||||
|
<div class="column is-4">
|
||||||
<label class="label is-size-7" for="create_name">Name</label>
|
<label class="label is-size-7" for="create_name">Name</label>
|
||||||
<input id="create_name" class="input is-small" name="name" placeholder="name" value="Business Plan">
|
<input id="create_name" class="input is-small" name="name" placeholder="name" value="Business Plan">
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column is-4">
|
||||||
<label class="label is-size-7" for="create_trigger_token">Trigger Token</label>
|
<label class="label is-size-7" for="create_trigger_token">Primary Trigger Token</label>
|
||||||
<input id="create_trigger_token" class="input is-small" name="trigger_token" placeholder="trigger token" value="#bp#">
|
<input id="create_trigger_token" class="input is-small" name="trigger_token" value="#bp#" readonly>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="label is-size-7" for="create_template_text">Template Text</label>
|
<label class="label is-size-7" for="create_template_text">BP Template (used only by <code>bp</code> in AI mode)</label>
|
||||||
<textarea id="create_template_text" class="textarea is-small" name="template_text" rows="4" placeholder="Business plan template"></textarea>
|
<textarea id="create_template_text" class="textarea is-small" name="template_text" rows="4" placeholder="Business plan template"></textarea>
|
||||||
<button class="button is-link is-small" style="margin-top: 0.75rem;" type="submit">Create Profile</button>
|
<button class="button is-link is-small" style="margin-top: 0.75rem;" type="submit">Create Profile</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -37,53 +51,33 @@
|
|||||||
<article class="box">
|
<article class="box">
|
||||||
<h2 class="title is-6">{{ profile.name }} ({{ profile.slug }})</h2>
|
<h2 class="title is-6">{{ profile.name }} ({{ profile.slug }})</h2>
|
||||||
<div class="content is-size-7" style="margin-bottom: 0.6rem;">
|
<div class="content is-size-7" style="margin-bottom: 0.6rem;">
|
||||||
<p><strong>Flag Definitions</strong></p>
|
<p><strong>Help</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>enabled</strong>: master on/off switch for this command profile.</li>
|
<li><strong>Send plan to egress</strong>: posts generated plan to enabled egress bindings.</li>
|
||||||
<li><strong>reply required</strong>: command only runs when the trigger message is sent as a reply to another message.</li>
|
<li><strong>Send status to source</strong>: posts a short confirmation message in the source chat.</li>
|
||||||
<li><strong>exact match</strong>: message text must be exactly the trigger token (for example <code>#bp#</code>) with no extra text.</li>
|
<li><strong>Send status to egress</strong>: posts a short confirmation to egress channels.</li>
|
||||||
<li><strong>visibility = status_in_source</strong>: post command status updates back into the source channel.</li>
|
<li><strong>Template support</strong>: only <code>bp</code> uses the template, and only in AI mode.</li>
|
||||||
<li><strong>visibility = silent</strong>: do not post status updates in the source channel.</li>
|
|
||||||
<li><strong>binding direction ingress</strong>: channels where trigger messages are accepted.</li>
|
|
||||||
<li><strong>binding direction egress</strong>: channels where command outputs are posted.</li>
|
|
||||||
<li><strong>binding direction scratchpad_mirror</strong>: scratchpad/mirror channel used for relay-only behavior.</li>
|
|
||||||
<li><strong>action extract_bp</strong>: run AI extraction to produce business plan content.</li>
|
|
||||||
<li><strong>action save_document</strong>: save/editable document and revision history.</li>
|
|
||||||
<li><strong>action post_result</strong>: fan out generated result to enabled egress bindings.</li>
|
|
||||||
<li><strong>position</strong>: execution order (lower runs first).</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
{% if profile.slug == "bp" %}
|
|
||||||
<p><strong>Supported Triggers (BP)</strong></p>
|
|
||||||
<ul>
|
|
||||||
<li><code>#bp#</code>: primary BP trigger (uses the standard BP extraction flow).</li>
|
|
||||||
<li><code>#bp set#</code>: deterministic no-AI set/update from reply/addendum text.</li>
|
|
||||||
<li><code>#bp set range#</code>: deterministic no-AI set/update from reply-anchor to trigger range.</li>
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" style="margin-bottom: 0.75rem;" aria-label="Update command profile {{ profile.name }}">
|
<form method="post" style="margin-bottom: 0.75rem;" aria-label="Update command profile {{ profile.name }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="profile_update">
|
<input type="hidden" name="action" value="profile_update">
|
||||||
<input type="hidden" name="profile_id" value="{{ profile.id }}">
|
<input type="hidden" name="profile_id" value="{{ profile.id }}">
|
||||||
|
{% if scope_service and scope_identifier %}
|
||||||
|
<input type="hidden" name="service" value="{{ scope_service }}">
|
||||||
|
<input type="hidden" name="identifier" value="{{ scope_identifier }}">
|
||||||
|
{% endif %}
|
||||||
<div class="columns is-multiline">
|
<div class="columns is-multiline">
|
||||||
<div class="column is-3">
|
<div class="column is-3">
|
||||||
<label class="label is-size-7" for="profile_name_{{ profile.id }}">Name</label>
|
<label class="label is-size-7" for="profile_name_{{ profile.id }}">Name</label>
|
||||||
<input id="profile_name_{{ profile.id }}" class="input is-small" name="name" value="{{ profile.name }}">
|
<input id="profile_name_{{ profile.id }}" class="input is-small" name="name" value="{{ profile.name }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-2">
|
<div class="column is-3">
|
||||||
<label class="label is-size-7" for="trigger_token_{{ profile.id }}">Trigger</label>
|
<label class="label is-size-7" for="trigger_token_{{ profile.id }}">Primary Trigger</label>
|
||||||
<input id="trigger_token_{{ profile.id }}" class="input is-small" name="trigger_token" value="{{ profile.trigger_token }}">
|
<input id="trigger_token_{{ profile.id }}" class="input is-small" name="trigger_token" value="{{ profile.trigger_token }}" readonly>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-2">
|
<div class="column is-6">
|
||||||
<label class="label is-size-7" for="visibility_mode_{{ profile.id }}">Visibility</label>
|
|
||||||
<div class="select is-small is-fullwidth">
|
|
||||||
<select id="visibility_mode_{{ profile.id }}" name="visibility_mode">
|
|
||||||
<option value="status_in_source" {% if profile.visibility_mode == 'status_in_source' %}selected{% endif %}>Show Status In Source Chat</option>
|
|
||||||
<option value="silent" {% if profile.visibility_mode == 'silent' %}selected{% endif %}>Silent (No Status Message)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-5">
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend class="label is-size-7">Flags</legend>
|
<legend class="label is-size-7">Flags</legend>
|
||||||
<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"><input type="checkbox" name="enabled" value="1" {% if profile.enabled %}checked{% endif %}> enabled</label>
|
||||||
@@ -93,26 +87,148 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="label is-size-7" for="template_text_{{ profile.id }}">BP Template</label>
|
<label class="label is-size-7" for="template_text_{{ profile.id }}">BP Template</label>
|
||||||
<textarea id="template_text_{{ profile.id }}" class="textarea is-small" name="template_text" rows="5">{{ profile.template_text }}</textarea>
|
<textarea id="template_text_{{ profile.id }}" class="textarea is-small" name="template_text" rows="4">{{ profile.template_text }}</textarea>
|
||||||
<div class="buttons" style="margin-top: 0.6rem;">
|
<div class="buttons" style="margin-top: 0.6rem;">
|
||||||
<button class="button is-link is-small" type="submit">Save Profile</button>
|
<button class="button is-link is-small" type="submit">Save Profile</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="columns">
|
<div class="columns is-variable is-5">
|
||||||
|
<div class="column">
|
||||||
|
<h3 class="title is-7">Variant Policies</h3>
|
||||||
|
<p class="help">Delivery switches control where plan/status are posted. Egress bindings define destinations.</p>
|
||||||
|
<p class="help">Turn off <strong>Save Document</strong> to run/fanout without storing a business plan artifact.</p>
|
||||||
|
<table class="table is-fullwidth is-striped is-size-7">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Variant</th>
|
||||||
|
<th>Trigger</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Generation</th>
|
||||||
|
<th>Save Document</th>
|
||||||
|
<th>Plan -> Egress</th>
|
||||||
|
<th>Status -> Source</th>
|
||||||
|
<th>Status -> Egress</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for variant in profile.variant_rows %}
|
||||||
|
<tr>
|
||||||
|
<form method="post" aria-label="Update variant policy {{ variant.variant_label }} for {{ profile.name }}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="variant_policy_update">
|
||||||
|
<input type="hidden" name="profile_id" value="{{ profile.id }}">
|
||||||
|
<input type="hidden" name="variant_key" value="{{ variant.variant_key }}">
|
||||||
|
{% if scope_service and scope_identifier %}
|
||||||
|
<input type="hidden" name="service" value="{{ scope_service }}">
|
||||||
|
<input type="hidden" name="identifier" value="{{ scope_identifier }}">
|
||||||
|
{% endif %}
|
||||||
|
<td>
|
||||||
|
{{ variant.variant_label }}
|
||||||
|
{% if not variant.template_supported %}
|
||||||
|
<span class="tag is-warning is-light is-small has-text-dark">no template</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><code>{{ variant.trigger_token }}</code></td>
|
||||||
|
<td><input type="checkbox" name="enabled" value="1" {% if variant.row.enabled %}checked{% endif %}></td>
|
||||||
|
<td>
|
||||||
|
<div class="select is-small">
|
||||||
|
<select name="generation_mode">
|
||||||
|
<option value="ai" {% if variant.row.generation_mode == 'ai' %}selected{% endif %}>AI</option>
|
||||||
|
<option value="verbatim" {% if variant.row.generation_mode == 'verbatim' %}selected{% endif %}>Verbatim</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><input type="checkbox" name="store_document" value="1" {% if variant.row.store_document %}checked{% endif %}></td>
|
||||||
|
<td><input type="checkbox" name="send_plan_to_egress" value="1" {% if variant.row.send_plan_to_egress %}checked{% endif %}></td>
|
||||||
|
<td><input type="checkbox" name="send_status_to_source" value="1" {% if variant.row.send_status_to_source %}checked{% endif %}></td>
|
||||||
|
<td><input type="checkbox" name="send_status_to_egress" value="1" {% if variant.row.send_status_to_egress %}checked{% endif %}></td>
|
||||||
|
<td><button class="button is-small is-link is-light" type="submit">Save</button></td>
|
||||||
|
</form>
|
||||||
|
</tr>
|
||||||
|
{% if variant.warn_verbatim_plan %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="9">
|
||||||
|
<p class="help has-text-warning-dark">
|
||||||
|
Warning: <strong>{{ variant.variant_label }}</strong> is in <code>verbatim</code> mode with plan fanout enabled.
|
||||||
|
Recipients will get raw transcript-style output.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="9">No variants configured.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.75rem; margin-top: 0.75rem; flex-wrap: wrap;">
|
||||||
|
<form method="post" aria-label="Preview delivery for {{ profile.name }}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="variant_preview">
|
||||||
|
<input type="hidden" name="profile_id" value="{{ profile.id }}">
|
||||||
|
{% if scope_service and scope_identifier %}
|
||||||
|
<input type="hidden" name="service" value="{{ scope_service }}">
|
||||||
|
<input type="hidden" name="identifier" value="{{ scope_identifier }}">
|
||||||
|
{% endif %}
|
||||||
|
<button class="button is-small is-info is-light" type="submit">Dry Run Preview</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" aria-label="Reset variant defaults for {{ profile.name }}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="variant_policy_reset_defaults">
|
||||||
|
<input type="hidden" name="profile_id" value="{{ profile.id }}">
|
||||||
|
{% if scope_service and scope_identifier %}
|
||||||
|
<input type="hidden" name="service" value="{{ scope_service }}">
|
||||||
|
<input type="hidden" name="identifier" value="{{ scope_identifier }}">
|
||||||
|
{% endif %}
|
||||||
|
<button class="button is-small is-warning is-light" type="submit">Reset Variant Defaults</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="title is-7" style="margin-top: 0.95rem;">Effective Destinations</h4>
|
||||||
|
{% if profile.enabled_egress_bindings %}
|
||||||
|
<ul class="is-size-7">
|
||||||
|
{% for row in profile.enabled_egress_bindings %}
|
||||||
|
<li>{{ row.service }} · <code>{{ row.channel_identifier }}</code></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p class="help">{{ profile.enabled_egress_bindings|length }} enabled egress destination{{ profile.enabled_egress_bindings|length|pluralize }}.</p>
|
||||||
|
{% else %}
|
||||||
|
<article class="notification is-warning is-light is-size-7">No enabled egress destinations. Plan fanout will show sent:0.</article>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if preview_profile_id and preview_profile_id == profile.id|stringformat:"s" %}
|
||||||
|
<article class="notification is-info is-light is-size-7" style="margin-top: 0.65rem;">
|
||||||
|
<p><strong>Dry Run Preview</strong></p>
|
||||||
|
<ul>
|
||||||
|
{% for variant in profile.variant_rows %}
|
||||||
|
<li>
|
||||||
|
{{ variant.variant_label }}: {% if variant.row.enabled %}enabled{% else %}disabled{% endif %}, mode={{ variant.row.generation_mode }},
|
||||||
|
save_document={{ variant.row.store_document }},
|
||||||
|
plan->egress={{ variant.row.send_plan_to_egress }},
|
||||||
|
status->source={{ variant.row.send_status_to_source }},
|
||||||
|
status->egress={{ variant.row.send_status_to_egress }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<h3 class="title is-7">Channel Bindings</h3>
|
<h3 class="title is-7">Channel Bindings</h3>
|
||||||
<p class="help">A command runs only when the source channel is in <code>ingress</code>. Output is sent to all enabled <code>egress</code> bindings.</p>
|
<p class="help">Ingress accepts triggers. Egress receives plan/status fanout if enabled in variant policy.</p>
|
||||||
<table class="table is-fullwidth is-striped is-size-7">
|
<table class="table is-fullwidth is-striped is-size-7">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th scope="col">Direction</th><th scope="col">Service</th><th scope="col">Channel</th><th scope="col">Actions</th></tr>
|
<tr><th scope="col">Direction</th><th scope="col">Service</th><th scope="col">Channel</th><th scope="col">Actions</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for binding in profile.channel_bindings.all %}
|
{% for binding in profile.visible_bindings %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{% if binding.direction == "ingress" %}Ingress (Accept Triggers)
|
{% if binding.direction == "ingress" %}Ingress (Accept Triggers)
|
||||||
{% elif binding.direction == "egress" %}Egress (Post Results)
|
{% elif binding.direction == "egress" %}Egress (Delivery Destination)
|
||||||
{% else %}Scratchpad Mirror
|
{% else %}Scratchpad Mirror
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
@@ -123,6 +239,10 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="binding_delete">
|
<input type="hidden" name="action" value="binding_delete">
|
||||||
<input type="hidden" name="binding_id" value="{{ binding.id }}">
|
<input type="hidden" name="binding_id" value="{{ binding.id }}">
|
||||||
|
{% if scope_service and scope_identifier %}
|
||||||
|
<input type="hidden" name="service" value="{{ scope_service }}">
|
||||||
|
<input type="hidden" name="identifier" value="{{ scope_identifier }}">
|
||||||
|
{% endif %}
|
||||||
<button class="button is-danger is-light is-small" type="submit">Delete</button>
|
<button class="button is-danger is-light is-small" type="submit">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
@@ -136,6 +256,10 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="binding_create">
|
<input type="hidden" name="action" value="binding_create">
|
||||||
<input type="hidden" name="profile_id" value="{{ profile.id }}">
|
<input type="hidden" name="profile_id" value="{{ profile.id }}">
|
||||||
|
{% if scope_service and scope_identifier %}
|
||||||
|
<input type="hidden" name="service" value="{{ scope_service }}">
|
||||||
|
<input type="hidden" name="identifier" value="{{ scope_identifier }}">
|
||||||
|
{% endif %}
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<label class="label is-size-7" for="binding_direction_{{ profile.id }}">Direction</label>
|
<label class="label is-size-7" for="binding_direction_{{ profile.id }}">Direction</label>
|
||||||
@@ -144,7 +268,7 @@
|
|||||||
{% for value in directions %}
|
{% for value in directions %}
|
||||||
<option value="{{ value }}">
|
<option value="{{ value }}">
|
||||||
{% if value == "ingress" %}Ingress (Accept Triggers)
|
{% if value == "ingress" %}Ingress (Accept Triggers)
|
||||||
{% elif value == "egress" %}Egress (Post Results)
|
{% elif value == "egress" %}Egress (Delivery Destination)
|
||||||
{% else %}Scratchpad Mirror
|
{% else %}Scratchpad Mirror
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</option>
|
</option>
|
||||||
@@ -155,16 +279,19 @@
|
|||||||
<div class="column">
|
<div class="column">
|
||||||
<label class="label is-size-7" for="binding_service_{{ profile.id }}">Service</label>
|
<label class="label is-size-7" for="binding_service_{{ profile.id }}">Service</label>
|
||||||
<div class="select is-small is-fullwidth">
|
<div class="select is-small is-fullwidth">
|
||||||
<select id="binding_service_{{ profile.id }}" name="service">
|
<select id="binding_service_{{ profile.id }}" name="service" {% if scope_service %}disabled{% endif %}>
|
||||||
{% for value in channel_services %}
|
{% for value in channel_services %}
|
||||||
<option value="{{ value }}">{{ value }}</option>
|
<option value="{{ value }}" {% if value == scope_service %}selected{% endif %}>{{ value }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{% if scope_service %}
|
||||||
|
<input type="hidden" name="service" value="{{ scope_service }}">
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<label class="label is-size-7" for="binding_channel_identifier_{{ profile.id }}">Channel Identifier</label>
|
<label class="label is-size-7" for="binding_channel_identifier_{{ profile.id }}">Channel Identifier</label>
|
||||||
<input id="binding_channel_identifier_{{ profile.id }}" class="input is-small" name="channel_identifier" placeholder="channel identifier">
|
<input id="binding_channel_identifier_{{ profile.id }}" class="input is-small" name="channel_identifier" placeholder="channel identifier" value="{{ scope_identifier }}" {% if scope_identifier %}readonly{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<button class="button is-link is-small" type="submit">Add</button>
|
<button class="button is-link is-small" type="submit">Add</button>
|
||||||
@@ -172,13 +299,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<h3 class="title is-7">Actions</h3>
|
<h3 class="title is-7">Actions</h3>
|
||||||
<p class="help">Enable/disable each step and set execution order with <code>position</code>.</p>
|
<p class="help">Enable/disable each step and use the reorder capsule to change execution order.</p>
|
||||||
<table class="table is-fullwidth is-striped is-size-7">
|
<table class="table is-fullwidth is-striped is-size-7">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th scope="col">Type</th><th scope="col">Enabled</th><th scope="col">Order</th><th scope="col">Actions</th></tr>
|
<tr><th scope="col">Type</th><th scope="col">Enabled</th><th scope="col">Reorder</th><th scope="col">Actions</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for action_row in profile.actions.all %}
|
{% for action_row in profile.actions.all %}
|
||||||
@@ -191,28 +320,41 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ action_row.enabled }}</td>
|
<td>{{ action_row.enabled }}</td>
|
||||||
<td>{{ forloop.counter }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<div class="buttons are-small" style="margin-bottom: 0.35rem;">
|
<span class="command-order-capsule">
|
||||||
<form method="post" style="display:inline;" aria-label="Move action {{ action_row.action_type }} up for {{ profile.name }}">
|
<form method="post" class="command-order-capsule-form" aria-label="Move action {{ action_row.action_type }} up for {{ profile.name }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="action_move">
|
<input type="hidden" name="action" value="action_move">
|
||||||
<input type="hidden" name="command_action_id" value="{{ action_row.id }}">
|
<input type="hidden" name="command_action_id" value="{{ action_row.id }}">
|
||||||
<input type="hidden" name="direction" value="up">
|
<input type="hidden" name="direction" value="up">
|
||||||
<button class="button is-light" type="submit" {% if forloop.first %}disabled{% endif %} aria-label="Move up">Up</button>
|
{% if scope_service and scope_identifier %}
|
||||||
|
<input type="hidden" name="service" value="{{ scope_service }}">
|
||||||
|
<input type="hidden" name="identifier" value="{{ scope_identifier }}">
|
||||||
|
{% endif %}
|
||||||
|
<button class="button is-small is-light command-order-btn" type="submit" {% if forloop.first %}disabled{% endif %} aria-label="Move up">▲</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" style="display:inline;" aria-label="Move action {{ action_row.action_type }} down for {{ profile.name }}">
|
<form method="post" class="command-order-capsule-form" aria-label="Move action {{ action_row.action_type }} down for {{ profile.name }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="action_move">
|
<input type="hidden" name="action" value="action_move">
|
||||||
<input type="hidden" name="command_action_id" value="{{ action_row.id }}">
|
<input type="hidden" name="command_action_id" value="{{ action_row.id }}">
|
||||||
<input type="hidden" name="direction" value="down">
|
<input type="hidden" name="direction" value="down">
|
||||||
<button class="button is-light" type="submit" {% if forloop.last %}disabled{% endif %} aria-label="Move down">Down</button>
|
{% if scope_service and scope_identifier %}
|
||||||
|
<input type="hidden" name="service" value="{{ scope_service }}">
|
||||||
|
<input type="hidden" name="identifier" value="{{ scope_identifier }}">
|
||||||
|
{% endif %}
|
||||||
|
<button class="button is-small is-light command-order-btn" type="submit" {% if forloop.last %}disabled{% endif %} aria-label="Move down">▼</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
<form method="post" aria-label="Update action {{ action_row.action_type }} for {{ profile.name }}">
|
<form method="post" aria-label="Update action {{ action_row.action_type }} for {{ profile.name }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="action_update">
|
<input type="hidden" name="action" value="action_update">
|
||||||
<input type="hidden" name="command_action_id" value="{{ action_row.id }}">
|
<input type="hidden" name="command_action_id" value="{{ action_row.id }}">
|
||||||
|
{% if scope_service and scope_identifier %}
|
||||||
|
<input type="hidden" name="service" value="{{ scope_service }}">
|
||||||
|
<input type="hidden" name="identifier" value="{{ scope_identifier }}">
|
||||||
|
{% endif %}
|
||||||
<label class="checkbox is-size-7"><input type="checkbox" name="enabled" value="1" {% if action_row.enabled %}checked{% endif %}> enabled</label>
|
<label class="checkbox is-size-7"><input type="checkbox" name="enabled" value="1" {% if action_row.enabled %}checked{% endif %}> enabled</label>
|
||||||
<button class="button is-link is-light is-small" type="submit">Save</button>
|
<button class="button is-link is-light is-small" type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -230,6 +372,10 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="profile_delete">
|
<input type="hidden" name="action" value="profile_delete">
|
||||||
<input type="hidden" name="profile_id" value="{{ profile.id }}">
|
<input type="hidden" name="profile_id" value="{{ profile.id }}">
|
||||||
|
{% if scope_service and scope_identifier %}
|
||||||
|
<input type="hidden" name="service" value="{{ scope_service }}">
|
||||||
|
<input type="hidden" name="identifier" value="{{ scope_identifier }}">
|
||||||
|
{% endif %}
|
||||||
<button class="button is-danger is-light is-small" type="submit" aria-label="Delete profile {{ profile.name }}">Delete Profile</button>
|
<button class="button is-danger is-light is-small" type="submit" aria-label="Delete profile {{ profile.name }}">Delete Profile</button>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
@@ -261,4 +407,34 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<style>
|
||||||
|
.command-order-capsule {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #dbdbdb;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
width: 2.15rem;
|
||||||
|
min-width: 2.15rem;
|
||||||
|
}
|
||||||
|
.command-order-capsule-form {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.command-order-btn {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 2.15rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.command-order-capsule-form + .command-order-capsule-form .command-order-btn {
|
||||||
|
border-top: 1px solid #dbdbdb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,220 +3,208 @@
|
|||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container tasks-settings-page">
|
<div class="container tasks-settings-page">
|
||||||
<h1 class="title is-4">Task Settings</h1>
|
<h1 class="title is-4">Task Settings</h1>
|
||||||
<p class="subtitle is-6">Configure task derivation, chat mapping, completion parsing, and external sync behavior.</p>
|
<p class="subtitle is-6">Project defaults flow into channel overrides. Use Quick Setup for normal operation; open Advanced Setup for full controls.</p>
|
||||||
|
|
||||||
<article class="box">
|
<div class="notification is-light">
|
||||||
<h2 class="title is-6">Setting Definitions</h2>
|
|
||||||
<div class="content is-size-7">
|
<div class="content is-size-7">
|
||||||
<p><strong>Projects</strong>: top-level containers for derived tasks. A single group can map to any project.</p>
|
<p><strong>How Matching Works</strong></p>
|
||||||
<p><strong>Epics</strong>: optional sub-grouping inside a project. Use these for parallel workstreams in the same project.</p>
|
<p><strong>Safe default behavior</strong>: strict matching, required prefixes, completion parsing enabled, and task-id announcements disabled.</p>
|
||||||
<p><strong>Group Mapping</strong>: binds a chat channel (service + channel identifier) to a project and optional epic. Task extraction only runs where mappings exist.</p>
|
<p><strong>Hierarchy</strong>: <strong>Project</strong> flags are defaults. A mapped <strong>channel</strong> can override those defaults without changing project-wide behavior.</p>
|
||||||
<p><strong>Matching Hierarchy</strong>: channel mapping flags override project flags. Project flags are defaults; mapping flags are per-chat precision controls.</p>
|
<p><strong>Matching modes</strong>: <code>strict</code> (prefix only), <code>balanced</code> (prefix + limited hints), <code>broad</code> (more permissive, higher false-positive risk).</p>
|
||||||
<p><strong>False-Positive Controls</strong>: defaults are safe: <code>match_mode=strict</code>, <code>require_prefix=true</code>, and prefixes <code>task:</code>/<code>todo:</code>. Freeform matching is off by default.</p>
|
</div>
|
||||||
<p><strong>Task ID Announcements</strong>: when enabled, newly derived tasks post an in-chat confirmation containing the new task reference (for example <code>#17</code>). Default is off.</p>
|
|
||||||
<p><strong>Legacy Backfill</strong>: opening this page applies safe defaults to older project and mapping rows created before strict prefix-only matching.</p>
|
|
||||||
<p><strong>Completion Phrases</strong>: explicit trigger words used to detect completion markers like <code>done #12</code>, <code>completed #12</code>, <code>fixed #12</code>.</p>
|
|
||||||
<p><strong>Provider</strong>: external sync adapter toggle. In current setup, mock provider validates append-only sync flow and retry behavior.</p>
|
|
||||||
<p><strong>Sync Event Log</strong>: audit of provider sync attempts and outcomes. Retry replays the event without mutating immutable task source records.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
|
||||||
|
|
||||||
{% if prefill_service and prefill_identifier %}
|
<section class="block box">
|
||||||
<article class="box">
|
<h2 class="title is-6">Quick Setup</h2>
|
||||||
<h2 class="title is-6">Quick Setup For Current Chat</h2>
|
<p class="help">Creates or updates project + optional epic + channel mapping in one submission.</p>
|
||||||
<p class="help">Prefilled from compose for <code>{{ prefill_service }}</code> · <code>{{ prefill_identifier }}</code>. Create/update project + epic + channel mapping in one step.</p>
|
<p class="help">After setup, view tasks in <a href="{% url 'tasks_hub' %}">Tasks Hub</a>{% if prefill_service and prefill_identifier %} or <a href="{% url 'tasks_group' service=prefill_service identifier=prefill_identifier %}">this group task view</a>{% endif %}.</p>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="quick_setup">
|
<input type="hidden" name="action" value="quick_setup">
|
||||||
<input type="hidden" name="service" value="{{ prefill_service }}">
|
|
||||||
<input type="hidden" name="channel_identifier" value="{{ prefill_identifier }}">
|
|
||||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
|
||||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
|
||||||
<div class="columns tasks-settings-inline-columns">
|
|
||||||
<div class="column">
|
|
||||||
<label class="label is-size-7">Project</label>
|
|
||||||
<input class="input is-small" name="project_name" placeholder="Project name">
|
|
||||||
</div>
|
|
||||||
<div class="column">
|
|
||||||
<label class="label is-size-7">Epic (optional)</label>
|
|
||||||
<input class="input is-small" name="epic_name" placeholder="Epic name">
|
|
||||||
</div>
|
|
||||||
<div class="column">
|
|
||||||
<label class="label is-size-7">Match Mode</label>
|
|
||||||
<div class="select is-small is-fullwidth">
|
|
||||||
<select name="source_match_mode">
|
|
||||||
<option value="strict">strict</option>
|
|
||||||
<option value="balanced">balanced</option>
|
|
||||||
<option value="broad">broad</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column">
|
|
||||||
<label class="label is-size-7">Prefixes</label>
|
|
||||||
<input class="input is-small" name="source_allowed_prefixes" value="task:,todo:">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label class="checkbox is-size-7"><input type="checkbox" name="source_require_prefix" value="1" checked> Require Prefix</label>
|
|
||||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_completion_enabled" value="1" checked> Completion Enabled</label>
|
|
||||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_derive_enabled" value="1" checked> Derivation Enabled</label>
|
|
||||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_announce_task_id" value="1"> Announce Task ID</label>
|
|
||||||
<button class="button is-small is-link" type="submit" style="margin-left: 0.75rem;">Apply Quick Setup</button>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="columns is-multiline tasks-settings-grid">
|
|
||||||
<div class="column is-6">
|
|
||||||
<article class="box">
|
|
||||||
<h2 class="title is-6">Projects</h2>
|
|
||||||
<p class="help">Create project scopes used by group mappings and derived tasks.</p>
|
|
||||||
<form method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="project_create">
|
|
||||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
|
||||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Name</label>
|
|
||||||
<input class="input is-small" name="name" placeholder="Project name">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Match Mode</label>
|
|
||||||
<div class="select is-small is-fullwidth">
|
|
||||||
<select name="match_mode">
|
|
||||||
<option value="strict">strict</option>
|
|
||||||
<option value="balanced">balanced</option>
|
|
||||||
<option value="broad">broad</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Allowed Prefixes (comma-separated)</label>
|
|
||||||
<input class="input is-small" name="allowed_prefixes" value="task:,todo:">
|
|
||||||
</div>
|
|
||||||
<label class="checkbox is-size-7"><input type="checkbox" name="require_prefix" value="1" checked> Require Prefix</label>
|
|
||||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="derive_enabled" value="1" checked> Derivation Enabled</label>
|
|
||||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="completion_enabled" value="1" checked> Completion Enabled</label>
|
|
||||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="announce_task_id" value="1"> Announce Task ID</label>
|
|
||||||
<button class="button is-small is-link" type="submit">Add Project</button>
|
|
||||||
</form>
|
|
||||||
<ul class="tasks-settings-list">
|
|
||||||
{% for row in projects %}
|
|
||||||
<li>
|
|
||||||
{{ row.name }}
|
|
||||||
<span class="has-text-grey">
|
|
||||||
mode={{ row.settings_effective.match_mode }},
|
|
||||||
prefixes={{ row.allowed_prefixes_csv }},
|
|
||||||
require_prefix={{ row.settings_effective.require_prefix }},
|
|
||||||
announce_id={{ row.settings_effective.announce_task_id }}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
{% empty %}
|
|
||||||
<li>No projects.</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-6">
|
|
||||||
<article class="box">
|
|
||||||
<h2 class="title is-6">Epics</h2>
|
|
||||||
<p class="help">Create project-local epics to refine routing and reporting.</p>
|
|
||||||
<form method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="epic_create">
|
|
||||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
|
||||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Project</label>
|
|
||||||
<div class="select is-small is-fullwidth">
|
|
||||||
<select name="project_id">
|
|
||||||
{% for p in projects %}<option value="{{ p.id }}">{{ p.name }}</option>{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Name</label>
|
|
||||||
<input class="input is-small" name="name" placeholder="Epic name">
|
|
||||||
</div>
|
|
||||||
<button class="button is-small is-link" type="submit">Add Epic</button>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-12">
|
|
||||||
<article class="box">
|
|
||||||
<h2 class="title is-6">Group Mapping (Chat -> Project/Epic)</h2>
|
|
||||||
<p class="help">Each mapped group becomes eligible for derived task extraction and completion tracking.</p>
|
|
||||||
<form method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="source_create">
|
|
||||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
|
||||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
|
||||||
<div class="columns tasks-settings-inline-columns">
|
<div class="columns tasks-settings-inline-columns">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<label class="label is-size-7">Service</label>
|
<label class="label is-size-7">Service</label>
|
||||||
<div class="select is-small is-fullwidth">
|
<div class="select is-small is-fullwidth">
|
||||||
<select name="service">
|
<select name="service">
|
||||||
<option {% if prefill_service == 'web' %}selected{% endif %}>web</option>
|
<option value="web" {% if prefill_service == 'web' %}selected{% endif %}>web</option>
|
||||||
<option {% if prefill_service == 'xmpp' %}selected{% endif %}>xmpp</option>
|
<option value="xmpp" {% if prefill_service == 'xmpp' %}selected{% endif %}>xmpp</option>
|
||||||
<option {% if prefill_service == 'signal' %}selected{% endif %}>signal</option>
|
<option value="signal" {% if prefill_service == 'signal' %}selected{% endif %}>signal</option>
|
||||||
<option {% if prefill_service == 'whatsapp' %}selected{% endif %}>whatsapp</option>
|
<option value="whatsapp" {% if prefill_service == 'whatsapp' or not prefill_service %}selected{% endif %}>whatsapp</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="help">Platform to watch for task extraction.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<label class="label is-size-7">Channel Identifier</label>
|
<label class="label is-size-7">Channel Identifier</label>
|
||||||
<input class="input is-small" name="channel_identifier" placeholder="service-native group/channel id" value="{{ prefill_identifier }}">
|
<input class="input is-small" name="channel_identifier" value="{{ prefill_identifier }}" placeholder="120...@g.us">
|
||||||
|
<p class="help">Exact chat/group id where messages are monitored.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<label class="label is-size-7">Project</label>
|
<label class="label is-size-7">Project</label>
|
||||||
<div class="select is-small is-fullwidth">
|
<input class="input is-small" name="project_name" placeholder="Project name">
|
||||||
<select name="project_id">
|
<p class="help">Top-level container for derived tasks.</p>
|
||||||
{% for p in projects %}<option value="{{ p.id }}">{{ p.name }}</option>{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<label class="label is-size-7">Epic (optional)</label>
|
<label class="label is-size-7">Epic (optional)</label>
|
||||||
<div class="select is-small is-fullwidth">
|
<input class="input is-small" name="epic_name" placeholder="Epic name">
|
||||||
<select name="epic_id">
|
<p class="help">Optional sub-container within a project.</p>
|
||||||
<option value="">-</option>
|
|
||||||
{% for e in epics %}<option value="{{ e.id }}">{{ e.project.name }} / {{ e.name }}</option>{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-narrow">
|
|
||||||
<button class="button is-small is-link" type="submit" style="margin-top: 1.8rem;">Add</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns tasks-settings-inline-columns">
|
<div class="columns tasks-settings-inline-columns">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<label class="label is-size-7">Match Mode</label>
|
<label class="label is-size-7">Match Mode</label>
|
||||||
<div class="select is-small is-fullwidth">
|
<div class="select is-small is-fullwidth">
|
||||||
<select name="source_match_mode">
|
<select name="source_match_mode">
|
||||||
<option value="strict">strict</option>
|
<option value="strict" selected>strict</option>
|
||||||
<option value="balanced">balanced</option>
|
<option value="balanced">balanced</option>
|
||||||
<option value="broad">broad</option>
|
<option value="broad">broad</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="help">strict = safest, balanced = moderate, broad = permissive.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<label class="label is-size-7">Allowed Prefixes</label>
|
<label class="label is-size-7">Allowed Prefixes</label>
|
||||||
<input class="input is-small" name="source_allowed_prefixes" value="task:,todo:">
|
<input id="quick-prefixes" class="input is-small" name="source_allowed_prefixes" value="task:,todo:">
|
||||||
|
<p class="help">Click to add:
|
||||||
|
<button type="button" class="button is-small is-light prefix-chip" data-target="quick-prefixes" data-prefix="task:">task:</button>
|
||||||
|
<button type="button" class="button is-small is-light prefix-chip" data-target="quick-prefixes" data-prefix="todo:">todo:</button>
|
||||||
|
<button type="button" class="button is-small is-light prefix-chip" data-target="quick-prefixes" data-prefix="action:">action:</button>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column is-narrow">
|
||||||
<label class="label is-size-7">Min Chars</label>
|
<label class="label is-size-7">Min Chars</label>
|
||||||
<input class="input is-small" name="source_min_chars" value="3">
|
<input class="input is-small" name="source_min_chars" value="3">
|
||||||
|
<p class="help">Minimum length after prefix.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field is-grouped is-grouped-multiline">
|
||||||
<label class="checkbox is-size-7"><input type="checkbox" name="source_require_prefix" value="1" checked> Require Prefix</label>
|
<label class="checkbox is-size-7"><input type="checkbox" name="source_require_prefix" value="1" checked> Require Prefix</label>
|
||||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_derive_enabled" value="1" checked> Derivation Enabled</label>
|
<label class="checkbox is-size-7"><input type="checkbox" name="source_derive_enabled" value="1" checked> Derivation Enabled</label>
|
||||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_completion_enabled" value="1" checked> Completion Enabled</label>
|
<label class="checkbox is-size-7"><input type="checkbox" name="source_completion_enabled" value="1" checked> Completion Enabled</label>
|
||||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_ai_title_enabled" value="1" checked> AI Title Enabled</label>
|
<label class="checkbox is-size-7"><input type="checkbox" name="source_ai_title_enabled" value="1" checked> AI Title Enabled</label>
|
||||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_announce_task_id" value="1"> Announce Task ID</label>
|
<label class="checkbox is-size-7"><input type="checkbox" name="source_announce_task_id" value="1"> Announce Task ID</label>
|
||||||
|
</div>
|
||||||
|
<p class="help">
|
||||||
|
<strong>Require Prefix</strong>: only prefixed messages can create tasks.
|
||||||
|
<strong>Derivation Enabled</strong>: master on/off for extraction.
|
||||||
|
<strong>Completion Enabled</strong>: parse completion phrases like <code>done #12</code>.
|
||||||
|
<strong>AI Title Enabled</strong>: normalize task titles using AI.
|
||||||
|
<strong>Announce Task ID</strong>: send bot confirmation on creation.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button class="button is-link is-small mt-3" type="submit">Apply Quick Setup</button>
|
||||||
</form>
|
</form>
|
||||||
<table class="table is-fullwidth is-size-7">
|
</section>
|
||||||
<thead><tr><th>Chat</th><th>Project</th><th>Epic</th><th>Match</th><th>Announce</th></tr></thead>
|
|
||||||
|
<details class="tasks-advanced box" {% if not prefill_identifier %}open{% endif %}>
|
||||||
|
<summary class="title is-6">Advanced Setup</summary>
|
||||||
|
<p class="help">Manual controls for creating hierarchy entities, mapping channels, and overriding behavior.</p>
|
||||||
|
|
||||||
|
<div class="columns is-multiline tasks-settings-grid">
|
||||||
|
<div class="column is-6">
|
||||||
|
<section class="tasks-panel">
|
||||||
|
<h3 class="title is-7">Projects</h3>
|
||||||
|
<p class="help">Create projects and review their effective defaults.</p>
|
||||||
|
<form method="post" class="block">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="project_create">
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control is-expanded"><input class="input is-small" name="name" placeholder="Project name"></div>
|
||||||
|
<div class="control"><button class="button is-small is-link" type="submit">Add Project</button></div>
|
||||||
|
</div>
|
||||||
|
<p class="help">Project names should describe a long-running stream of work.</p>
|
||||||
|
</form>
|
||||||
|
<table class="table is-fullwidth is-striped is-size-7">
|
||||||
|
<thead><tr><th>Project</th><th>Defaults</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in projects %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.name }}</td>
|
||||||
|
<td>mode={{ row.settings_effective.match_mode }}, prefixes={{ row.allowed_prefixes_csv }}, announce_id={{ row.settings_effective.announce_task_id }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="2">No projects.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-6">
|
||||||
|
<section class="tasks-panel">
|
||||||
|
<h3 class="title is-7">Epics</h3>
|
||||||
|
<p class="help">Epics are optional subdivisions under a project.</p>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="epic_create">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-size-7">Project + Epic</label>
|
||||||
|
</div>
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control">
|
||||||
|
<div class="select is-small">
|
||||||
|
<select name="project_id">{% for p in projects %}<option value="{{ p.id }}">{{ p.name }}</option>{% endfor %}</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control is-expanded"><input class="input is-small" name="name" placeholder="Epic name"></div>
|
||||||
|
<div class="control"><button class="button is-small is-link" type="submit">Add Epic</button></div>
|
||||||
|
</div>
|
||||||
|
<p class="help">Choose the parent project first, then add the epic name.</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-12">
|
||||||
|
<section class="tasks-panel">
|
||||||
|
<h3 class="title is-7">Group Mapping</h3>
|
||||||
|
<p class="help">Map a channel to a project/epic. Channel flags can later override project defaults.</p>
|
||||||
|
<form method="post" class="block">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="source_create">
|
||||||
|
<div class="columns tasks-settings-inline-columns">
|
||||||
|
<div class="column">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-size-7">Service</label>
|
||||||
|
<div class="control">
|
||||||
|
<div class="select is-small is-fullwidth"><select name="service"><option>web</option><option>xmpp</option><option>signal</option><option>whatsapp</option></select></div>
|
||||||
|
</div>
|
||||||
|
<p class="help">Service/platform for this mapping.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-size-7">Channel Identifier</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input is-small" name="channel_identifier" value="{{ prefill_identifier }}" placeholder="channel">
|
||||||
|
</div>
|
||||||
|
<p class="help">Exact identifier for the chat/group.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-size-7">Project</label>
|
||||||
|
<div class="control">
|
||||||
|
<div class="select is-small is-fullwidth"><select name="project_id">{% for p in projects %}<option value="{{ p.id }}">{{ p.name }}</option>{% endfor %}</select></div>
|
||||||
|
</div>
|
||||||
|
<p class="help">Project receiving derived tasks.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-size-7">Epic (optional)</label>
|
||||||
|
<div class="control">
|
||||||
|
<div class="select is-small is-fullwidth"><select name="epic_id"><option value="">-</option>{% for e in epics %}<option value="{{ e.id }}">{{ e.project.name }} / {{ e.name }}</option>{% endfor %}</select></div>
|
||||||
|
</div>
|
||||||
|
<p class="help">Optional epic within that project.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow"><button class="button is-small is-link" type="submit">Add</button></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<table class="table is-fullwidth is-striped is-size-7">
|
||||||
|
<thead><tr><th>Chat</th><th>Project</th><th>Epic</th><th>Match</th><th>Announce</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in sources %}
|
{% for row in sources %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -225,180 +213,135 @@
|
|||||||
<td>{{ row.epic.name }}</td>
|
<td>{{ row.epic.name }}</td>
|
||||||
<td>{{ row.settings_effective.match_mode }}{% if row.settings_effective.require_prefix %} +prefix{% endif %}</td>
|
<td>{{ row.settings_effective.match_mode }}{% if row.settings_effective.require_prefix %} +prefix{% endif %}</td>
|
||||||
<td>{{ row.settings_effective.announce_task_id }}</td>
|
<td>{{ row.settings_effective.announce_task_id }}</td>
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr><td colspan="5">No mappings.</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-6">
|
|
||||||
<article class="box">
|
|
||||||
<h2 class="title is-6">Project Matching Flags</h2>
|
|
||||||
<p class="help">Project defaults apply to all mapped chats unless channel-level override changes them.</p>
|
|
||||||
<form method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="project_flags_update">
|
|
||||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
|
||||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Project</label>
|
|
||||||
<div class="select is-small is-fullwidth">
|
|
||||||
<select name="project_id">
|
|
||||||
{% for p in projects %}
|
|
||||||
<option value="{{ p.id }}">{{ p.name }} · mode={{ p.settings_effective.match_mode }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Match Mode</label>
|
|
||||||
<div class="select is-small is-fullwidth">
|
|
||||||
<select name="match_mode">
|
|
||||||
<option value="strict">strict</option>
|
|
||||||
<option value="balanced">balanced</option>
|
|
||||||
<option value="broad">broad</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Allowed Prefixes</label>
|
|
||||||
<input class="input is-small" name="allowed_prefixes" value="task:,todo:">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Min Chars</label>
|
|
||||||
<input class="input is-small" name="min_chars" value="3">
|
|
||||||
</div>
|
|
||||||
<label class="checkbox is-size-7"><input type="checkbox" name="require_prefix" value="1" checked> Require Prefix</label>
|
|
||||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="derive_enabled" value="1" checked> Derivation Enabled</label>
|
|
||||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="completion_enabled" value="1" checked> Completion Enabled</label>
|
|
||||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="announce_task_id" value="1"> Announce Task ID</label>
|
|
||||||
<button class="button is-small is-link is-light" type="submit" style="margin-left: 0.75rem;">Save Project Flags</button>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="box">
|
|
||||||
<h2 class="title is-6">Channel Override Flags</h2>
|
|
||||||
<p class="help">These flags override project defaults for one mapped chat only.</p>
|
|
||||||
<form method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="source_flags_update">
|
|
||||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
|
||||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Mapped Channel</label>
|
|
||||||
<div class="select is-small is-fullwidth">
|
|
||||||
<select name="source_id">
|
|
||||||
{% for s in sources %}
|
|
||||||
<option value="{{ s.id }}">{{ s.service }} · {{ s.channel_identifier }} · {{ s.project.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Match Mode</label>
|
|
||||||
<div class="select is-small is-fullwidth">
|
|
||||||
<select name="source_match_mode">
|
|
||||||
<option value="strict">strict</option>
|
|
||||||
<option value="balanced">balanced</option>
|
|
||||||
<option value="broad">broad</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Allowed Prefixes</label>
|
|
||||||
<input class="input is-small" name="source_allowed_prefixes" value="task:,todo:">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Min Chars</label>
|
|
||||||
<input class="input is-small" name="source_min_chars" value="3">
|
|
||||||
</div>
|
|
||||||
<label class="checkbox is-size-7"><input type="checkbox" name="source_require_prefix" value="1" checked> Require Prefix</label>
|
|
||||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_derive_enabled" value="1" checked> Derivation Enabled</label>
|
|
||||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_completion_enabled" value="1" checked> Completion Enabled</label>
|
|
||||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_ai_title_enabled" value="1" checked> AI Title Enabled</label>
|
|
||||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_announce_task_id" value="1"> Announce Task ID</label>
|
|
||||||
<button class="button is-small is-link is-light" type="submit" style="margin-left: 0.75rem;">Save Channel Flags</button>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-6">
|
|
||||||
<article class="box">
|
|
||||||
<h2 class="title is-6">Completion Phrases</h2>
|
|
||||||
<p class="help">Add parser phrases for completion statements followed by a task reference, e.g. <code>done #12</code>.</p>
|
|
||||||
<form method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="pattern_create">
|
|
||||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
|
||||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-size-7">Phrase</label>
|
|
||||||
<input class="input is-small" name="phrase" placeholder="done">
|
|
||||||
</div>
|
|
||||||
<button class="button is-small is-link" type="submit">Add Phrase</button>
|
|
||||||
</form>
|
|
||||||
<ul class="tasks-settings-list">
|
|
||||||
{% for row in patterns %}<li>{{ row.phrase }}</li>{% empty %}<li>No phrases.</li>{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-6">
|
|
||||||
<article class="box">
|
|
||||||
<h2 class="title is-6">Provider</h2>
|
|
||||||
<p class="help">Enable/disable external sync adapter and review recent provider event outcomes.</p>
|
|
||||||
<form method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="provider_update">
|
|
||||||
<input type="hidden" name="provider" value="mock">
|
|
||||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
|
||||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
|
||||||
<label class="checkbox"><input type="checkbox" name="enabled" value="1" {% if provider_configs and provider_configs.0.enabled %}checked{% endif %}> Enable mock provider</label>
|
|
||||||
<button class="button is-small is-link is-light" type="submit">Save</button>
|
|
||||||
</form>
|
|
||||||
<table class="table is-fullwidth is-size-7 tasks-settings-table">
|
|
||||||
<thead><tr><th>Updated</th><th>Provider</th><th>Status</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in sync_events %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ row.updated_at }}</td>
|
|
||||||
<td>{{ row.provider }}</td>
|
|
||||||
<td>{{ row.status }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<form method="post">
|
<form method="post" aria-label="Delete mapping {{ row.service }} {{ row.channel_identifier }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="sync_retry">
|
<input type="hidden" name="action" value="source_delete">
|
||||||
<input type="hidden" name="event_id" value="{{ row.id }}">
|
<input type="hidden" name="source_id" value="{{ row.id }}">
|
||||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
<button class="button is-danger is-light is-small" type="submit">Delete</button>
|
||||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
|
||||||
<button class="button is-small is-light" type="submit">Retry</button>
|
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr><td colspan="4">No sync events.</td></tr>
|
<tr><td colspan="6">No mappings.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</article>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-6">
|
||||||
|
<section class="tasks-panel">
|
||||||
|
<h3 class="title is-7">Project Defaults (All Mapped Chats)</h3>
|
||||||
|
<p class="help">Set baseline extraction behavior for a project. Every mapped chat inherits this unless overridden below.</p>
|
||||||
|
<form method="post" class="tasks-flag-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="project_flags_update">
|
||||||
|
<div class="field"><label class="label is-size-7">Project</label><div class="select is-small is-fullwidth"><select name="project_id">{% for p in projects %}<option value="{{ p.id }}">{{ p.name }}</option>{% endfor %}</select></div></div>
|
||||||
|
<div class="field"><label class="label is-size-7">Match Mode</label><div class="select is-small is-fullwidth"><select name="match_mode"><option value="strict" selected>strict</option><option value="balanced">balanced</option><option value="broad">broad</option></select></div></div>
|
||||||
|
<div class="field"><label class="label is-size-7">Allowed Prefixes</label><input id="proj-prefixes" class="input is-small" name="allowed_prefixes" value="task:,todo:"></div>
|
||||||
|
<div class="field"><label class="label is-size-7">Min Chars</label><input class="input is-small" name="min_chars" value="3"></div>
|
||||||
|
<div class="field is-grouped is-grouped-multiline">
|
||||||
|
<label class="checkbox is-size-7"><input type="checkbox" name="require_prefix" value="1" checked> Require Prefix</label>
|
||||||
|
<label class="checkbox is-size-7"><input type="checkbox" name="derive_enabled" value="1" checked> Derivation Enabled</label>
|
||||||
|
<label class="checkbox is-size-7"><input type="checkbox" name="completion_enabled" value="1" checked> Completion Enabled</label>
|
||||||
|
<label class="checkbox is-size-7"><input type="checkbox" name="announce_task_id" value="1"> Announce Task ID</label>
|
||||||
|
</div>
|
||||||
|
<p class="help">
|
||||||
|
<button type="button" class="button is-small is-light prefix-chip" data-target="proj-prefixes" data-prefix="task:">task:</button>
|
||||||
|
<button type="button" class="button is-small is-light prefix-chip" data-target="proj-prefixes" data-prefix="todo:">todo:</button>
|
||||||
|
<button type="button" class="button is-small is-light prefix-chip" data-target="proj-prefixes" data-prefix="action:">action:</button>
|
||||||
|
</p>
|
||||||
|
<button class="button is-small is-link is-light" type="submit">Save Project Flags</button>
|
||||||
|
</form>
|
||||||
|
<p class="help">
|
||||||
|
<strong>Require Prefix</strong>: allow task creation only with configured prefixes.
|
||||||
|
<strong>Derivation Enabled</strong>: turn extraction on/off for this project.
|
||||||
|
<strong>Completion Enabled</strong>: enable completion phrase parser.
|
||||||
|
<strong>Announce Task ID</strong>: emit confirmation messages on task creation.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-6">
|
||||||
|
<section class="tasks-panel">
|
||||||
|
<h3 class="title is-7">Channel Override Flags</h3>
|
||||||
|
<p class="help">Channel-level override. Use only where this chat should behave differently from the project default.</p>
|
||||||
|
<form method="post" class="tasks-flag-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="source_flags_update">
|
||||||
|
<div class="field"><label class="label is-size-7">Mapped Channel</label><div class="select is-small is-fullwidth"><select name="source_id">{% for s in sources %}<option value="{{ s.id }}">{{ s.service }} · {{ s.channel_identifier }} · {{ s.project.name }}</option>{% endfor %}</select></div></div>
|
||||||
|
<div class="field"><label class="label is-size-7">Match Mode</label><div class="select is-small is-fullwidth"><select name="source_match_mode"><option value="strict" selected>strict</option><option value="balanced">balanced</option><option value="broad">broad</option></select></div></div>
|
||||||
|
<div class="field"><label class="label is-size-7">Allowed Prefixes</label><input id="source-prefixes" class="input is-small" name="source_allowed_prefixes" value="task:,todo:"></div>
|
||||||
|
<div class="field"><label class="label is-size-7">Min Chars</label><input class="input is-small" name="source_min_chars" value="3"></div>
|
||||||
|
<div class="field is-grouped is-grouped-multiline">
|
||||||
|
<label class="checkbox is-size-7"><input type="checkbox" name="source_require_prefix" value="1" checked> Require Prefix</label>
|
||||||
|
<label class="checkbox is-size-7"><input type="checkbox" name="source_derive_enabled" value="1" checked> Derivation Enabled</label>
|
||||||
|
<label class="checkbox is-size-7"><input type="checkbox" name="source_completion_enabled" value="1" checked> Completion Enabled</label>
|
||||||
|
<label class="checkbox is-size-7"><input type="checkbox" name="source_ai_title_enabled" value="1" checked> AI Title Enabled</label>
|
||||||
|
<label class="checkbox is-size-7"><input type="checkbox" name="source_announce_task_id" value="1"> Announce Task ID</label>
|
||||||
|
</div>
|
||||||
|
<p class="help">
|
||||||
|
<button type="button" class="button is-small is-light prefix-chip" data-target="source-prefixes" data-prefix="task:">task:</button>
|
||||||
|
<button type="button" class="button is-small is-light prefix-chip" data-target="source-prefixes" data-prefix="todo:">todo:</button>
|
||||||
|
<button type="button" class="button is-small is-light prefix-chip" data-target="source-prefixes" data-prefix="action:">action:</button>
|
||||||
|
</p>
|
||||||
|
<button class="button is-small is-link is-light" type="submit">Save Channel Flags</button>
|
||||||
|
</form>
|
||||||
|
<p class="help">
|
||||||
|
<strong>Require Prefix</strong>: enforce prefixes in this channel.
|
||||||
|
<strong>Derivation Enabled</strong>: extraction on/off for this channel only.
|
||||||
|
<strong>Completion Enabled</strong>: completion phrase parser in this channel.
|
||||||
|
<strong>AI Title Enabled</strong>: AI title normalization in this channel.
|
||||||
|
<strong>Announce Task ID</strong>: confirmation message in this channel.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-6">
|
||||||
|
<section class="tasks-panel">
|
||||||
|
<h3 class="title is-7">Completion Phrases</h3>
|
||||||
|
<p class="help">Add parser phrases for completion statements followed by a task reference, e.g. <code>done #12</code>.</p>
|
||||||
|
<form method="post" class="block">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="pattern_create">
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control is-expanded"><input class="input is-small" name="phrase" placeholder="done"></div>
|
||||||
|
<div class="control"><button class="button is-small is-link" type="submit">Add Phrase</button></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<ul class="tasks-settings-list">{% for row in patterns %}<li>{{ row.phrase }}</li>{% empty %}<li>No phrases.</li>{% endfor %}</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-6">
|
||||||
|
<section class="tasks-panel">
|
||||||
|
<h3 class="title is-7">Provider</h3>
|
||||||
|
<p class="help">Controls outbound sync to external tracking systems. If disabled, tasks are still derived and visible inside GIA only.</p>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="provider_update">
|
||||||
|
<input type="hidden" name="provider" value="mock">
|
||||||
|
<label class="checkbox"><input type="checkbox" name="enabled" value="1" {% if provider_configs and provider_configs.0.enabled %}checked{% endif %}> Enable mock provider</label>
|
||||||
|
<p class="help">Mock provider logs sync events without writing to a real third-party system.</p>
|
||||||
|
<div style="margin-top:0.5rem;">
|
||||||
|
<button class="button is-small is-link is-light" type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="help">Browse all derived tasks in <a href="{% url 'tasks_hub' %}">Tasks Hub</a>.</p>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.tasks-settings-page .tasks-settings-grid .column > .box {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.tasks-settings-page .tasks-settings-inline-columns {
|
.tasks-settings-page .tasks-settings-inline-columns {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
.tasks-settings-page .tasks-settings-inline-columns > .column {
|
.tasks-settings-page .tasks-settings-inline-columns > .column {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
@@ -407,11 +350,79 @@
|
|||||||
.tasks-settings-page .tasks-settings-inline-columns > .column:last-child {
|
.tasks-settings-page .tasks-settings-inline-columns > .column:last-child {
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
.tasks-settings-page .tasks-settings-inline-columns .help {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.tasks-settings-page .tasks-settings-inline-columns .field {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
.tasks-settings-page .tasks-settings-list {
|
.tasks-settings-page .tasks-settings-list {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
.tasks-settings-page .tasks-settings-table {
|
.tasks-settings-page .prefix-chip {
|
||||||
margin-top: 0.75rem;
|
margin-right: 0.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.tasks-settings-page .tasks-advanced {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.tasks-settings-page .tasks-advanced > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.tasks-settings-page .tasks-advanced > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tasks-settings-page .tasks-panel {
|
||||||
|
height: 100%;
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-top: 1px solid #ececec;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
padding-right: 0.1rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
.tasks-settings-page .tasks-settings-grid > .column {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.tasks-settings-page .tasks-settings-grid > .column > .tasks-panel {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.tasks-settings-page .tasks-settings-grid .table {
|
||||||
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const chips = Array.from(document.querySelectorAll('.tasks-settings-page .prefix-chip'));
|
||||||
|
if (!chips.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const addPrefix = function (targetId, prefix) {
|
||||||
|
const input = document.getElementById(targetId);
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = String(input.value || '').split(',').map(function (v) { return String(v || '').trim(); }).filter(Boolean);
|
||||||
|
const value = String(prefix || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!current.includes(value)) {
|
||||||
|
current.push(value);
|
||||||
|
}
|
||||||
|
input.value = current.join(',');
|
||||||
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
};
|
||||||
|
chips.forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
addPrefix(String(btn.dataset.target || ''), String(btn.dataset.prefix || ''));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -81,20 +81,33 @@
|
|||||||
<span>Commands</span>
|
<span>Commands</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="compose-command-menu-panel is-hidden">
|
<div class="compose-command-menu-panel is-hidden">
|
||||||
|
<p class="is-size-7 has-text-grey" style="margin-bottom: 0.45rem;">
|
||||||
|
This chat currently has {{ bp_binding_summary.ingress_count }} ingress and {{ bp_binding_summary.egress_count }} egress bindings for bp.
|
||||||
|
</p>
|
||||||
{% for option in command_options %}
|
{% for option in command_options %}
|
||||||
<label class="compose-command-option">
|
<label class="compose-command-option">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="compose-command-toggle"
|
class="compose-command-toggle"
|
||||||
data-command-slug="{{ option.slug }}"
|
data-command-slug="{{ option.toggle_slug|default:option.slug }}"
|
||||||
|
data-command-row-slug="{{ option.slug }}"
|
||||||
{% if option.enabled_here %}checked{% endif %}>
|
{% if option.enabled_here %}checked{% endif %}>
|
||||||
<span class="compose-command-option-title">{{ option.name }}</span>
|
<span class="compose-command-option-title">{{ option.name }}</span>
|
||||||
|
{% if option.mode_label %}
|
||||||
|
<span class="tag is-light is-info is-rounded compose-command-option-badge">{{ option.mode_label }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if option.enabled_label %}
|
||||||
|
<span class="tag is-light is-rounded compose-command-option-badge">{{ option.enabled_label }}</span>
|
||||||
|
{% endif %}
|
||||||
{% if option.trigger_token %}
|
{% if option.trigger_token %}
|
||||||
<span class="compose-command-option-token">{{ option.trigger_token }}</span>
|
<span class="compose-command-option-token">{{ option.trigger_token }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<a class="compose-command-settings-link" href="{% url 'command_routing' %}">Open command routing</a>
|
<p class="help" style="margin-top: 0.5rem;">
|
||||||
|
Enabling a command in this menu enables ingress and egress for this chat identifier.
|
||||||
|
</p>
|
||||||
|
<a class="compose-command-settings-link" href="{% if command_routing_scoped_url %}{{ command_routing_scoped_url }}{% else %}{% url 'command_routing' %}{% endif %}">Open command routing</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="button is-light is-rounded compose-export-toggle" aria-expanded="false">
|
<button type="button" class="button is-light is-rounded compose-export-toggle" aria-expanded="false">
|
||||||
@@ -153,6 +166,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if signal_ingest_warning %}
|
||||||
|
<article class="message is-warning is-light" style="margin-top: 0.75rem; margin-bottom: 0.75rem;">
|
||||||
|
<div class="message-body is-size-7">
|
||||||
|
{{ signal_ingest_warning }}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div id="{{ panel_id }}-status" class="compose-status">
|
<div id="{{ panel_id }}-status" class="compose-status">
|
||||||
{% include "partials/compose-send-status.html" %}
|
{% include "partials/compose-send-status.html" %}
|
||||||
</div>
|
</div>
|
||||||
@@ -903,6 +924,12 @@
|
|||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .compose-command-option-badge {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
padding: 0 0.42rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
line-height: 1.15rem;
|
||||||
|
}
|
||||||
#{{ panel_id }} .compose-command-settings-link {
|
#{{ panel_id }} .compose-command-settings-link {
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
@@ -2154,6 +2181,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const shouldEnable = !!checkbox.checked;
|
const shouldEnable = !!checkbox.checked;
|
||||||
|
if (shouldEnable) {
|
||||||
|
const confirmText = "Enable this command for this chat and route output to configured egress channels?";
|
||||||
|
if (!window.confirm(confirmText)) {
|
||||||
|
checkbox.checked = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
checkbox.disabled = true;
|
checkbox.disabled = true;
|
||||||
try {
|
try {
|
||||||
const params = queryParams({
|
const params = queryParams({
|
||||||
@@ -2173,6 +2207,9 @@
|
|||||||
String(result.message || (slug + (shouldEnable ? " enabled." : " disabled."))),
|
String(result.message || (slug + (shouldEnable ? " enabled." : " disabled."))),
|
||||||
"success"
|
"success"
|
||||||
);
|
);
|
||||||
|
menu.querySelectorAll('.compose-command-toggle[data-command-slug=\"' + slug + '\"]').forEach(function (peer) {
|
||||||
|
peer.checked = shouldEnable;
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
checkbox.checked = !shouldEnable;
|
checkbox.checked = !shouldEnable;
|
||||||
setStatus("Failed to update command binding.", "danger");
|
setStatus("Failed to update command binding.", "danger");
|
||||||
|
|||||||
@@ -22,22 +22,24 @@
|
|||||||
<td>
|
<td>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
{% if account_unlink_url_name %}
|
{% if account_unlink_url_name %}
|
||||||
hx-delete="{% url account_unlink_url_name type=type account=item %}"
|
hx-post="{% url account_unlink_url_name type=type account=item %}"
|
||||||
hx-trigger="click"
|
hx-trigger="click"
|
||||||
hx-target="#{{ context_object_name|slugify }}-panel"
|
hx-target="#{{ context_object_name|slugify }}-panel"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if account_unlink_url_name %}
|
{% if account_unlink_url_name %}
|
||||||
hx-confirm="Are you sure you wish to unlink {{ item }}?"
|
hx-confirm="Unlink {{ item }} from this bridge client so you can relink by scanning a new QR code?"
|
||||||
{% endif %}
|
{% endif %}
|
||||||
class="button"
|
class="button is-light"
|
||||||
{% if not account_unlink_url_name %}disabled{% endif %}>
|
{% if not account_unlink_url_name %}disabled{% endif %}>
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa-solid fa-xmark"></i>
|
<i class="fa-solid fa-rotate"></i>
|
||||||
</span>
|
</span>
|
||||||
|
<span>{{ account_unlink_label|default:"Unlink" }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{% if show_contact_actions %}
|
{% if show_contact_actions %}
|
||||||
@@ -97,9 +99,15 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
{% if account_unlink_label == "Relink" %}
|
||||||
|
<p class="help" style="margin-bottom: 0.6rem;">
|
||||||
|
Relink flow: click <strong>Relink</strong> on the current account, then use
|
||||||
|
<strong>Add account</strong> below to generate and scan a fresh QR code.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
<form
|
<form
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
hx-post="{% url account_add_url_name type=type %}"
|
hx-post="{% url account_add_url_name type=account_add_type|default:type %}"
|
||||||
hx-target="{% if account_add_target %}{{ account_add_target }}{% else %}#widgets-here{% endif %}"
|
hx-target="{% if account_add_target %}{{ account_add_target }}{% else %}#widgets-here{% endif %}"
|
||||||
hx-swap="{% if account_add_swap %}{{ account_add_swap }}{% else %}innerHTML{% endif %}">
|
hx-swap="{% if account_add_swap %}{{ account_add_swap }}{% else %}innerHTML{% endif %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
{% load cache %}
|
|
||||||
{% include 'mixins/partials/notify.html' %}
|
{% include 'mixins/partials/notify.html' %}
|
||||||
{% cache 600 objects_signal_chats request.user.id object_list type %}
|
|
||||||
<table
|
<table
|
||||||
class="table is-fullwidth is-hoverable"
|
class="table is-fullwidth is-hoverable"
|
||||||
hx-target="#{{ context_object_name }}-table"
|
hx-target="#{{ context_object_name }}-table"
|
||||||
@@ -146,4 +144,3 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
{% endcache %}
|
|
||||||
|
|||||||
54
core/tests/test_command_routing_variant_ui.py
Normal file
54
core/tests/test_command_routing_variant_ui.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from core.commands.policies import ensure_variant_policies_for_profile
|
||||||
|
from core.models import CommandProfile, User
|
||||||
|
|
||||||
|
|
||||||
|
class CommandRoutingVariantUITests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="routing-user",
|
||||||
|
email="routing@example.com",
|
||||||
|
password="x",
|
||||||
|
)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
ensure_variant_policies_for_profile(self.profile)
|
||||||
|
|
||||||
|
def test_command_routing_page_shows_variant_policy_table(self):
|
||||||
|
response = self.client.get(reverse("command_routing"))
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
self.assertContains(response, "Variant Policies")
|
||||||
|
self.assertContains(response, "bp set range")
|
||||||
|
self.assertContains(response, "Send status to egress")
|
||||||
|
|
||||||
|
def test_variant_policy_update_persists(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("command_routing"),
|
||||||
|
{
|
||||||
|
"action": "variant_policy_update",
|
||||||
|
"profile_id": str(self.profile.id),
|
||||||
|
"variant_key": "bp_set",
|
||||||
|
"enabled": "1",
|
||||||
|
"generation_mode": "ai",
|
||||||
|
"send_plan_to_egress": "1",
|
||||||
|
"send_status_to_source": "1",
|
||||||
|
"send_status_to_egress": "1",
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
row = self.profile.variant_policies.get(variant_key="bp_set")
|
||||||
|
self.assertEqual("ai", row.generation_mode)
|
||||||
|
self.assertTrue(row.send_status_to_egress)
|
||||||
225
core/tests/test_command_variant_policy.py
Normal file
225
core/tests/test_command_variant_policy.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
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.commands.policies import ensure_variant_policies_for_profile
|
||||||
|
from core.models import (
|
||||||
|
BusinessPlanDocument,
|
||||||
|
AI,
|
||||||
|
ChatSession,
|
||||||
|
CommandAction,
|
||||||
|
CommandChannelBinding,
|
||||||
|
CommandProfile,
|
||||||
|
CommandVariantPolicy,
|
||||||
|
Message,
|
||||||
|
Person,
|
||||||
|
PersonIdentifier,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CommandVariantPolicyTests(TransactionTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="variant-user",
|
||||||
|
email="variant@example.com",
|
||||||
|
password="x",
|
||||||
|
)
|
||||||
|
self.person = Person.objects.create(user=self.user, name="Variant 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,
|
||||||
|
visibility_mode="status_in_source",
|
||||||
|
template_text="TEMPLATE SHOULD NOT LEAK INTO bp set",
|
||||||
|
)
|
||||||
|
AI.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
base_url="https://example.invalid",
|
||||||
|
api_key="test-key",
|
||||||
|
model="gpt-4o-mini",
|
||||||
|
)
|
||||||
|
CommandAction.objects.create(
|
||||||
|
profile=self.profile,
|
||||||
|
action_type="extract_bp",
|
||||||
|
enabled=True,
|
||||||
|
position=0,
|
||||||
|
)
|
||||||
|
CommandAction.objects.create(
|
||||||
|
profile=self.profile,
|
||||||
|
action_type="save_document",
|
||||||
|
enabled=True,
|
||||||
|
position=1,
|
||||||
|
)
|
||||||
|
CommandAction.objects.create(
|
||||||
|
profile=self.profile,
|
||||||
|
action_type="post_result",
|
||||||
|
enabled=True,
|
||||||
|
position=2,
|
||||||
|
)
|
||||||
|
CommandChannelBinding.objects.create(
|
||||||
|
profile=self.profile,
|
||||||
|
direction="ingress",
|
||||||
|
service="whatsapp",
|
||||||
|
channel_identifier="120363402761690215",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
CommandChannelBinding.objects.create(
|
||||||
|
profile=self.profile,
|
||||||
|
direction="egress",
|
||||||
|
service="whatsapp",
|
||||||
|
channel_identifier="120363402761690215",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _ctx(self, trigger: Message, text: str) -> CommandContext:
|
||||||
|
return CommandContext(
|
||||||
|
service="whatsapp",
|
||||||
|
channel_identifier="120363402761690215",
|
||||||
|
message_id=str(trigger.id),
|
||||||
|
user_id=self.user.id,
|
||||||
|
message_text=text,
|
||||||
|
payload={},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ensure_variant_policies_backfills_bp_defaults(self):
|
||||||
|
rows = ensure_variant_policies_for_profile(self.profile)
|
||||||
|
self.assertSetEqual(set(rows.keys()), {"bp", "bp_set", "bp_set_range"})
|
||||||
|
self.assertEqual("ai", rows["bp"].generation_mode)
|
||||||
|
self.assertEqual("verbatim", rows["bp_set"].generation_mode)
|
||||||
|
self.assertEqual("verbatim", rows["bp_set_range"].generation_mode)
|
||||||
|
self.assertTrue(rows["bp"].send_plan_to_egress)
|
||||||
|
self.assertTrue(rows["bp"].send_status_to_source)
|
||||||
|
|
||||||
|
def test_bp_primary_can_run_in_verbatim_mode_without_ai(self):
|
||||||
|
ensure_variant_policies_for_profile(self.profile)
|
||||||
|
policy = CommandVariantPolicy.objects.get(profile=self.profile, variant_key="bp")
|
||||||
|
policy.generation_mode = "verbatim"
|
||||||
|
policy.send_plan_to_egress = False
|
||||||
|
policy.send_status_to_source = False
|
||||||
|
policy.send_status_to_egress = False
|
||||||
|
policy.save()
|
||||||
|
|
||||||
|
anchor = Message.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
session=self.session,
|
||||||
|
sender_uuid="peer",
|
||||||
|
text="anchor line",
|
||||||
|
ts=1000,
|
||||||
|
source_service="whatsapp",
|
||||||
|
source_chat_id="120363402761690215",
|
||||||
|
)
|
||||||
|
trigger = Message.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
session=self.session,
|
||||||
|
sender_uuid="me",
|
||||||
|
text="#bp#",
|
||||||
|
ts=2000,
|
||||||
|
source_service="whatsapp",
|
||||||
|
source_chat_id="120363402761690215",
|
||||||
|
reply_to=anchor,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = async_to_sync(BPCommandHandler().execute)(self._ctx(trigger, "#bp#"))
|
||||||
|
self.assertTrue(result.ok)
|
||||||
|
doc = BusinessPlanDocument.objects.get(trigger_message=trigger)
|
||||||
|
self.assertEqual("anchor line\n#bp#", doc.content_markdown)
|
||||||
|
|
||||||
|
def test_bp_set_ai_mode_ignores_template(self):
|
||||||
|
ensure_variant_policies_for_profile(self.profile)
|
||||||
|
policy = CommandVariantPolicy.objects.get(profile=self.profile, variant_key="bp_set")
|
||||||
|
policy.generation_mode = "ai"
|
||||||
|
policy.send_plan_to_egress = False
|
||||||
|
policy.send_status_to_source = False
|
||||||
|
policy.send_status_to_egress = False
|
||||||
|
policy.save()
|
||||||
|
|
||||||
|
trigger = Message.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
session=self.session,
|
||||||
|
sender_uuid="me",
|
||||||
|
text="#bp set# text to transform",
|
||||||
|
ts=1000,
|
||||||
|
source_service="whatsapp",
|
||||||
|
source_chat_id="120363402761690215",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"core.commands.handlers.bp.ai_runner.run_prompt",
|
||||||
|
new=AsyncMock(return_value="AI RESULT"),
|
||||||
|
) as mocked:
|
||||||
|
result = async_to_sync(BPCommandHandler().execute)(
|
||||||
|
self._ctx(trigger, trigger.text)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result.ok)
|
||||||
|
doc = BusinessPlanDocument.objects.get(trigger_message=trigger)
|
||||||
|
self.assertEqual("AI RESULT", doc.content_markdown)
|
||||||
|
call_args = mocked.await_args.args
|
||||||
|
prompt_payload = call_args[0]
|
||||||
|
self.assertNotIn("TEMPLATE SHOULD NOT LEAK", str(prompt_payload))
|
||||||
|
|
||||||
|
def test_delivery_flags_control_source_and_egress_status(self):
|
||||||
|
ensure_variant_policies_for_profile(self.profile)
|
||||||
|
policy = CommandVariantPolicy.objects.get(
|
||||||
|
profile=self.profile,
|
||||||
|
variant_key="bp_set_range",
|
||||||
|
)
|
||||||
|
policy.generation_mode = "verbatim"
|
||||||
|
policy.store_document = False
|
||||||
|
policy.send_plan_to_egress = False
|
||||||
|
policy.send_status_to_source = True
|
||||||
|
policy.send_status_to_egress = True
|
||||||
|
policy.save()
|
||||||
|
|
||||||
|
anchor = Message.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
session=self.session,
|
||||||
|
sender_uuid="peer",
|
||||||
|
text="line one",
|
||||||
|
ts=1000,
|
||||||
|
source_service="whatsapp",
|
||||||
|
source_chat_id="120363402761690215",
|
||||||
|
)
|
||||||
|
trigger = Message.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
session=self.session,
|
||||||
|
sender_uuid="me",
|
||||||
|
text="#bp set range#",
|
||||||
|
ts=2000,
|
||||||
|
source_service="whatsapp",
|
||||||
|
source_chat_id="120363402761690215",
|
||||||
|
reply_to=anchor,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"core.commands.handlers.bp.post_status_in_source",
|
||||||
|
new=AsyncMock(return_value=True),
|
||||||
|
) as source_status, patch(
|
||||||
|
"core.commands.handlers.bp.post_to_channel_binding",
|
||||||
|
new=AsyncMock(return_value=True),
|
||||||
|
) as binding_send:
|
||||||
|
result = async_to_sync(BPCommandHandler().execute)(
|
||||||
|
self._ctx(trigger, trigger.text)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result.ok)
|
||||||
|
source_status.assert_awaited()
|
||||||
|
self.assertEqual(1, binding_send.await_count)
|
||||||
|
self.assertFalse(BusinessPlanDocument.objects.filter(trigger_message=trigger).exists())
|
||||||
@@ -6,6 +6,7 @@ from django.test import TestCase
|
|||||||
from core.commands.base import CommandContext
|
from core.commands.base import CommandContext
|
||||||
from core.commands.engine import _matches_trigger, process_inbound_message
|
from core.commands.engine import _matches_trigger, process_inbound_message
|
||||||
from core.messaging.reply_sync import extract_reply_ref, resolve_reply_target
|
from core.messaging.reply_sync import extract_reply_ref, resolve_reply_target
|
||||||
|
from core.views.compose import _command_options_for_channel
|
||||||
from core.models import (
|
from core.models import (
|
||||||
ChatSession,
|
ChatSession,
|
||||||
CommandChannelBinding,
|
CommandChannelBinding,
|
||||||
@@ -123,6 +124,27 @@ class Phase1ReplyResolutionTests(TestCase):
|
|||||||
self.assertEqual("signal-msg-quoted", result.get("reply_source_message_id"))
|
self.assertEqual("signal-msg-quoted", result.get("reply_source_message_id"))
|
||||||
self.assertEqual("signal", result.get("reply_source_service"))
|
self.assertEqual("signal", result.get("reply_source_service"))
|
||||||
|
|
||||||
|
def test_extract_reply_ref_signal_target_sent_timestamp_variant(self):
|
||||||
|
result = extract_reply_ref(
|
||||||
|
"signal",
|
||||||
|
{
|
||||||
|
"envelope": {
|
||||||
|
"dataMessage": {
|
||||||
|
"quote": {
|
||||||
|
"targetSentTimestamp": 1772545268786,
|
||||||
|
"authorNumber": "+15550000001",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"1772545268786",
|
||||||
|
result.get("reply_source_message_id"),
|
||||||
|
)
|
||||||
|
self.assertEqual("signal", result.get("reply_source_service"))
|
||||||
|
self.assertEqual("+15550000001", result.get("reply_source_chat_id"))
|
||||||
|
|
||||||
def test_extract_reply_ref_whatsapp(self):
|
def test_extract_reply_ref_whatsapp(self):
|
||||||
result = extract_reply_ref(
|
result = extract_reply_ref(
|
||||||
"whatsapp",
|
"whatsapp",
|
||||||
@@ -272,3 +294,23 @@ class Phase1CommandEngineTests(TestCase):
|
|||||||
self.assertEqual(1, len(results))
|
self.assertEqual(1, len(results))
|
||||||
self.assertEqual("skipped", results[0].status)
|
self.assertEqual("skipped", results[0].status)
|
||||||
self.assertEqual("reply_required", results[0].error)
|
self.assertEqual("reply_required", results[0].error)
|
||||||
|
|
||||||
|
def test_compose_command_options_show_bp_subcommands(self):
|
||||||
|
self.profile.channel_bindings.all().delete()
|
||||||
|
CommandChannelBinding.objects.create(
|
||||||
|
profile=self.profile,
|
||||||
|
direction="ingress",
|
||||||
|
service="whatsapp",
|
||||||
|
channel_identifier="120363402761690215@g.us",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
options = _command_options_for_channel(
|
||||||
|
self.user,
|
||||||
|
"whatsapp",
|
||||||
|
"120363402761690215@g.us",
|
||||||
|
)
|
||||||
|
names = [str(row.get("name") or "").strip().lower() for row in options]
|
||||||
|
self.assertIn("bp", names)
|
||||||
|
self.assertIn("bp set", names)
|
||||||
|
self.assertIn("bp set range", names)
|
||||||
|
self.assertNotIn("announce task ids", names)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from core.models import (
|
|||||||
TaskProject,
|
TaskProject,
|
||||||
User,
|
User,
|
||||||
Message,
|
Message,
|
||||||
|
Chat,
|
||||||
)
|
)
|
||||||
from core.tasks.engine import process_inbound_task_intelligence
|
from core.tasks.engine import process_inbound_task_intelligence
|
||||||
|
|
||||||
@@ -136,3 +137,63 @@ class TaskEngineTests(TestCase):
|
|||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
DerivedTaskEvent.objects.filter(task=task, event_type="completion_marked").exists()
|
DerivedTaskEvent.objects.filter(task=task, event_type="completion_marked").exists()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_matches_whatsapp_private_channel_variants(self):
|
||||||
|
ChatTaskSource.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
service="whatsapp",
|
||||||
|
channel_identifier="447700900123@s.whatsapp.net",
|
||||||
|
project=self.project,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
m = Message.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
session=self.session,
|
||||||
|
sender_uuid="peer",
|
||||||
|
text="task: update private chat mapping",
|
||||||
|
ts=1200,
|
||||||
|
source_service="whatsapp",
|
||||||
|
source_chat_id="447700900123",
|
||||||
|
)
|
||||||
|
async_to_sync(process_inbound_task_intelligence)(m)
|
||||||
|
self.assertTrue(
|
||||||
|
DerivedTask.objects.filter(origin_message=m).exists(),
|
||||||
|
"Expected private WhatsApp bare identifier to match @s.whatsapp.net mapping.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_matches_signal_uuid_to_number_companion_mapping(self):
|
||||||
|
signal_person = Person.objects.create(user=self.user, name="Signal Task Person")
|
||||||
|
signal_identifier = PersonIdentifier.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
person=signal_person,
|
||||||
|
service="signal",
|
||||||
|
identifier="+447700900555",
|
||||||
|
)
|
||||||
|
signal_session = ChatSession.objects.create(user=self.user, identifier=signal_identifier)
|
||||||
|
ChatTaskSource.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
service="signal",
|
||||||
|
channel_identifier="+447700900555",
|
||||||
|
project=self.project,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
Chat.objects.create(
|
||||||
|
source_uuid="54cb8dbe-4c5f-4ef9-9f3d-4a9b37fd15d9",
|
||||||
|
source_number="+447700900555",
|
||||||
|
source_name="Signal Peer",
|
||||||
|
account="+447700900000",
|
||||||
|
)
|
||||||
|
m = Message.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
session=signal_session,
|
||||||
|
sender_uuid="peer",
|
||||||
|
text="task: check signal private mapping",
|
||||||
|
ts=1300,
|
||||||
|
source_service="signal",
|
||||||
|
source_chat_id="54cb8dbe-4c5f-4ef9-9f3d-4a9b37fd15d9",
|
||||||
|
)
|
||||||
|
async_to_sync(process_inbound_task_intelligence)(m)
|
||||||
|
self.assertTrue(
|
||||||
|
DerivedTask.objects.filter(origin_message=m).exists(),
|
||||||
|
"Expected Signal UUID source chat to match source mapping by companion number.",
|
||||||
|
)
|
||||||
|
|||||||
45
core/tests/test_signal_relink.py
Normal file
45
core/tests/test_signal_relink.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class SignalRelinkTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_superuser(
|
||||||
|
username="signal-admin",
|
||||||
|
email="signal-admin@example.com",
|
||||||
|
password="x",
|
||||||
|
)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
@patch("core.views.signal.transport.list_accounts")
|
||||||
|
def test_signal_accounts_view_shows_relink_action(self, mock_list_accounts):
|
||||||
|
mock_list_accounts.return_value = ["+447000000001"]
|
||||||
|
response = self.client.get(reverse("signal_accounts", kwargs={"type": "page"}))
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
self.assertContains(response, "Relink")
|
||||||
|
self.assertContains(response, "/services/signal/")
|
||||||
|
self.assertContains(response, "/unlink/+447000000001/")
|
||||||
|
|
||||||
|
@patch("core.views.signal.transport.list_accounts")
|
||||||
|
@patch("core.views.signal.transport.unlink_account")
|
||||||
|
def test_signal_account_unlink_calls_transport_and_renders_panel(
|
||||||
|
self,
|
||||||
|
mock_unlink_account,
|
||||||
|
mock_list_accounts,
|
||||||
|
):
|
||||||
|
mock_list_accounts.side_effect = [
|
||||||
|
["+447000000001"],
|
||||||
|
[],
|
||||||
|
]
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"signal_account_unlink",
|
||||||
|
kwargs={"type": "page", "account": "+447000000001"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
mock_unlink_account.assert_called_once_with("signal", "+447000000001")
|
||||||
223
core/tests/test_signal_reply_send.py
Normal file
223
core/tests/test_signal_reply_send.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test import TestCase, TransactionTestCase
|
||||||
|
|
||||||
|
from core.clients import transport
|
||||||
|
from core.clients.signal import SignalClient
|
||||||
|
from core.models import ChatSession, Message, Person, PersonIdentifier, User
|
||||||
|
from core.views.compose import _build_signal_reply_metadata
|
||||||
|
|
||||||
|
|
||||||
|
class SignalReplyMetadataTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="signal-reply-meta-user",
|
||||||
|
email="signal-reply-meta@example.com",
|
||||||
|
password="x",
|
||||||
|
)
|
||||||
|
self.person = Person.objects.create(user=self.user, name="Signal Reply")
|
||||||
|
self.identifier = PersonIdentifier.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
person=self.person,
|
||||||
|
service="signal",
|
||||||
|
identifier="+15550001000",
|
||||||
|
)
|
||||||
|
self.session = ChatSession.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
identifier=self.identifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_build_signal_reply_metadata_uses_signal_source(self):
|
||||||
|
incoming = Message.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
session=self.session,
|
||||||
|
sender_uuid="+15550001000",
|
||||||
|
text="quoted body",
|
||||||
|
ts=1772538353497,
|
||||||
|
source_service="signal",
|
||||||
|
source_message_id="1772538353497",
|
||||||
|
source_chat_id="+15550001000",
|
||||||
|
)
|
||||||
|
payload = _build_signal_reply_metadata(incoming, "+15550001000")
|
||||||
|
self.assertEqual(1772538353497, payload.get("quote_timestamp"))
|
||||||
|
self.assertEqual("+15550001000", payload.get("quote_author"))
|
||||||
|
self.assertEqual("quoted body", payload.get("quote_text"))
|
||||||
|
|
||||||
|
def test_build_signal_reply_metadata_uses_chat_number_when_sender_is_uuid(self):
|
||||||
|
incoming = Message.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
session=self.session,
|
||||||
|
sender_uuid="756078fd-d447-426d-a620-581a86d64f51",
|
||||||
|
text="quoted body",
|
||||||
|
ts=1772538353497,
|
||||||
|
source_service="signal",
|
||||||
|
source_message_id="1772538353497",
|
||||||
|
source_chat_id="+15550001000",
|
||||||
|
)
|
||||||
|
payload = _build_signal_reply_metadata(incoming, "+15550001000")
|
||||||
|
self.assertEqual("+15550001000", payload.get("quote_author"))
|
||||||
|
|
||||||
|
def test_build_signal_reply_metadata_uses_local_sender_for_own_messages(self):
|
||||||
|
outgoing = Message.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
session=self.session,
|
||||||
|
sender_uuid="",
|
||||||
|
custom_author="USER",
|
||||||
|
text="my previous message",
|
||||||
|
ts=1772538353900,
|
||||||
|
source_service="web",
|
||||||
|
source_message_id="1772538353900",
|
||||||
|
source_chat_id="+15550001000",
|
||||||
|
)
|
||||||
|
payload = _build_signal_reply_metadata(outgoing, "+15550001000")
|
||||||
|
expected_author = str(getattr(settings, "SIGNAL_NUMBER", "") or "").strip()
|
||||||
|
if expected_author:
|
||||||
|
self.assertEqual(expected_author, payload.get("quote_author"))
|
||||||
|
else:
|
||||||
|
self.assertEqual("+15550001000", payload.get("quote_author"))
|
||||||
|
|
||||||
|
|
||||||
|
class SignalTransportSendTests(TestCase):
|
||||||
|
def test_transport_passes_reply_metadata_to_signal_api(self):
|
||||||
|
with patch(
|
||||||
|
"core.clients.transport.prepare_outbound_attachments",
|
||||||
|
new=AsyncMock(return_value=[]),
|
||||||
|
), patch(
|
||||||
|
"core.clients.transport.signalapi.send_message_raw",
|
||||||
|
new=AsyncMock(return_value=1772538354000),
|
||||||
|
) as mocked_send:
|
||||||
|
result = async_to_sync(transport.send_message_raw)(
|
||||||
|
"signal",
|
||||||
|
"+15550001000",
|
||||||
|
text="reply payload",
|
||||||
|
attachments=[],
|
||||||
|
metadata={
|
||||||
|
"quote_timestamp": 1772538353497,
|
||||||
|
"quote_author": "+15550001000",
|
||||||
|
"quote_text": "quoted body",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1772538354000, result)
|
||||||
|
mocked_send.assert_awaited_once_with(
|
||||||
|
"+15550001000",
|
||||||
|
"reply payload",
|
||||||
|
[],
|
||||||
|
metadata={
|
||||||
|
"quote_timestamp": 1772538353497,
|
||||||
|
"quote_author": "+15550001000",
|
||||||
|
"quote_text": "quoted body",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SignalInboundReplyLinkTests(TransactionTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="signal-inbound-user",
|
||||||
|
email="signal-inbound@example.com",
|
||||||
|
password="x",
|
||||||
|
)
|
||||||
|
self.person = Person.objects.create(user=self.user, name="Signal Inbound")
|
||||||
|
self.identifier = PersonIdentifier.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
person=self.person,
|
||||||
|
service="signal",
|
||||||
|
identifier="+15550002000",
|
||||||
|
)
|
||||||
|
self.session = ChatSession.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
identifier=self.identifier,
|
||||||
|
)
|
||||||
|
self.anchor = Message.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
session=self.session,
|
||||||
|
sender_uuid="+15550002000",
|
||||||
|
text="anchor inbound",
|
||||||
|
ts=1772545458187,
|
||||||
|
source_service="signal",
|
||||||
|
source_message_id="1772545458187",
|
||||||
|
source_chat_id="+15550002000",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_process_raw_inbound_event_links_signal_reply(self):
|
||||||
|
fake_ur = Mock()
|
||||||
|
fake_ur.message_received = AsyncMock(return_value=None)
|
||||||
|
client = SignalClient.__new__(SignalClient)
|
||||||
|
client.service = "signal"
|
||||||
|
client.ur = fake_ur
|
||||||
|
client.log = Mock()
|
||||||
|
client.client = Mock()
|
||||||
|
client.client.bot_uuid = ""
|
||||||
|
client.client.phone_number = ""
|
||||||
|
client._resolve_signal_identifiers = AsyncMock(return_value=[self.identifier])
|
||||||
|
client._auto_link_single_user_signal_identifier = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"envelope": {
|
||||||
|
"sourceNumber": "+15550002000",
|
||||||
|
"sourceUuid": "756078fd-d447-426d-a620-581a86d64f51",
|
||||||
|
"timestamp": 1772545462051,
|
||||||
|
"dataMessage": {
|
||||||
|
"message": "reply inbound s3",
|
||||||
|
"quote": {
|
||||||
|
"targetSentTimestamp": 1772545458187,
|
||||||
|
"authorNumber": "+15550002000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async_to_sync(client._process_raw_inbound_event)(json.dumps(payload))
|
||||||
|
|
||||||
|
created = Message.objects.filter(
|
||||||
|
user=self.user,
|
||||||
|
session=self.session,
|
||||||
|
text="reply inbound s3",
|
||||||
|
).order_by("-ts").first()
|
||||||
|
self.assertIsNotNone(created)
|
||||||
|
self.assertEqual(self.anchor.id, created.reply_to_id)
|
||||||
|
self.assertEqual("1772545458187", created.reply_source_message_id)
|
||||||
|
|
||||||
|
def test_process_raw_inbound_event_applies_reaction(self):
|
||||||
|
fake_ur = Mock()
|
||||||
|
fake_ur.message_received = AsyncMock(return_value=None)
|
||||||
|
fake_ur.xmpp = Mock()
|
||||||
|
fake_ur.xmpp.client = Mock()
|
||||||
|
fake_ur.xmpp.client.apply_external_reaction = AsyncMock(return_value=None)
|
||||||
|
client = SignalClient.__new__(SignalClient)
|
||||||
|
client.service = "signal"
|
||||||
|
client.ur = fake_ur
|
||||||
|
client.log = Mock()
|
||||||
|
client.client = Mock()
|
||||||
|
client.client.bot_uuid = ""
|
||||||
|
client.client.phone_number = ""
|
||||||
|
client._resolve_signal_identifiers = AsyncMock(return_value=[self.identifier])
|
||||||
|
client._auto_link_single_user_signal_identifier = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"envelope": {
|
||||||
|
"sourceNumber": "+15550002000",
|
||||||
|
"sourceUuid": "756078fd-d447-426d-a620-581a86d64f51",
|
||||||
|
"timestamp": 1772545463000,
|
||||||
|
"dataMessage": {
|
||||||
|
"reaction": {
|
||||||
|
"emoji": "❤️",
|
||||||
|
"targetSentTimestamp": 1772545458187,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async_to_sync(client._process_raw_inbound_event)(json.dumps(payload))
|
||||||
|
|
||||||
|
self.anchor.refresh_from_db()
|
||||||
|
reactions = list((self.anchor.receipt_payload or {}).get("reactions") or [])
|
||||||
|
self.assertTrue(
|
||||||
|
any(str(row.get("emoji") or "") == "❤️" for row in reactions),
|
||||||
|
"Expected Signal heart reaction to be applied to anchor receipt payload.",
|
||||||
|
)
|
||||||
43
core/tests/test_signal_unlink_fallback.py
Normal file
43
core/tests/test_signal_unlink_fallback.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from core.clients import transport
|
||||||
|
|
||||||
|
|
||||||
|
class SignalUnlinkFallbackTests(TestCase):
|
||||||
|
@patch("core.clients.transport._wipe_signal_cli_local_state")
|
||||||
|
@patch("requests.delete")
|
||||||
|
def test_signal_unlink_uses_rest_delete_when_available(
|
||||||
|
self,
|
||||||
|
mock_delete,
|
||||||
|
mock_wipe,
|
||||||
|
):
|
||||||
|
ok_response = Mock()
|
||||||
|
ok_response.ok = True
|
||||||
|
mock_delete.return_value = ok_response
|
||||||
|
|
||||||
|
result = transport.unlink_account("signal", "+447700900000")
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
self.assertTrue(mock_delete.called)
|
||||||
|
mock_wipe.assert_not_called()
|
||||||
|
|
||||||
|
@patch("core.clients.transport._wipe_signal_cli_local_state")
|
||||||
|
@patch("requests.delete")
|
||||||
|
def test_signal_unlink_falls_back_to_local_wipe(
|
||||||
|
self,
|
||||||
|
mock_delete,
|
||||||
|
mock_wipe,
|
||||||
|
):
|
||||||
|
bad_response = Mock()
|
||||||
|
bad_response.ok = False
|
||||||
|
mock_delete.return_value = bad_response
|
||||||
|
mock_wipe.return_value = True
|
||||||
|
|
||||||
|
result = transport.unlink_account("signal", "+447700900000")
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
self.assertEqual(2, mock_delete.call_count)
|
||||||
|
mock_wipe.assert_called_once()
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
from django.urls import reverse
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
from core.models import (
|
from core.models import (
|
||||||
@@ -12,12 +13,13 @@ from core.models import (
|
|||||||
Message,
|
Message,
|
||||||
Person,
|
Person,
|
||||||
PersonIdentifier,
|
PersonIdentifier,
|
||||||
|
TaskCompletionPattern,
|
||||||
TaskProject,
|
TaskProject,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from core.tasks.engine import process_inbound_task_intelligence
|
from core.tasks.engine import process_inbound_task_intelligence
|
||||||
from core.views.compose import _command_options_for_channel, _toggle_task_announce_for_channel
|
from core.views.compose import _command_options_for_channel, _toggle_task_announce_for_channel
|
||||||
from core.views.tasks import _apply_safe_defaults_for_user
|
from core.views.tasks import _apply_safe_defaults_for_user, _ensure_default_completion_patterns
|
||||||
|
|
||||||
|
|
||||||
class TaskSettingsBackfillTests(TestCase):
|
class TaskSettingsBackfillTests(TestCase):
|
||||||
@@ -65,6 +67,13 @@ class TaskSettingsBackfillTests(TestCase):
|
|||||||
self.assertEqual("strict", self.source.settings.get("match_mode"))
|
self.assertEqual("strict", self.source.settings.get("match_mode"))
|
||||||
self.assertTrue(bool(self.source.settings.get("require_prefix")))
|
self.assertTrue(bool(self.source.settings.get("require_prefix")))
|
||||||
|
|
||||||
|
def test_default_completion_phrases_seeded(self):
|
||||||
|
_ensure_default_completion_patterns(self.user)
|
||||||
|
phrases = set(
|
||||||
|
TaskCompletionPattern.objects.filter(user=self.user).values_list("phrase", flat=True)
|
||||||
|
)
|
||||||
|
self.assertTrue({"done", "completed", "fixed"}.issubset(phrases))
|
||||||
|
|
||||||
|
|
||||||
class TaskAnnounceToggleTests(TestCase):
|
class TaskAnnounceToggleTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -98,14 +107,16 @@ class TaskAnnounceToggleTests(TestCase):
|
|||||||
self.source.refresh_from_db()
|
self.source.refresh_from_db()
|
||||||
self.assertTrue(bool(self.source.settings.get("announce_task_id")))
|
self.assertTrue(bool(self.source.settings.get("announce_task_id")))
|
||||||
|
|
||||||
def test_command_options_include_task_announce_state(self):
|
def test_command_options_include_bp_subcommands(self):
|
||||||
options = _command_options_for_channel(
|
options = _command_options_for_channel(
|
||||||
self.user,
|
self.user,
|
||||||
"whatsapp",
|
"whatsapp",
|
||||||
"120363402761690215",
|
"120363402761690215",
|
||||||
)
|
)
|
||||||
row = [opt for opt in options if opt.get("slug") == "task_announce"][0]
|
names = [str(row.get("name") or "").strip().lower() for row in options]
|
||||||
self.assertFalse(bool(row.get("enabled_here")))
|
self.assertIn("bp", names)
|
||||||
|
self.assertIn("bp set", names)
|
||||||
|
self.assertIn("bp set range", names)
|
||||||
|
|
||||||
|
|
||||||
@override_settings(TASK_DERIVATION_USE_AI=False)
|
@override_settings(TASK_DERIVATION_USE_AI=False)
|
||||||
@@ -161,3 +172,34 @@ class TaskAnnounceRuntimeTests(TestCase):
|
|||||||
async_to_sync(process_inbound_task_intelligence)(self._msg("task: rotate secrets"))
|
async_to_sync(process_inbound_task_intelligence)(self._msg("task: rotate secrets"))
|
||||||
self.assertTrue(DerivedTask.objects.exists())
|
self.assertTrue(DerivedTask.objects.exists())
|
||||||
mocked_send.assert_awaited()
|
mocked_send.assert_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
class TaskSettingsViewActionsTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user("task-settings-user", "ts@example.com", "x")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.project = TaskProject.objects.create(user=self.user, name="Project A")
|
||||||
|
self.source = ChatTaskSource.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
service="whatsapp",
|
||||||
|
channel_identifier="120363402761690215@g.us",
|
||||||
|
project=self.project,
|
||||||
|
settings={"match_mode": "strict"},
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_source_delete_removes_mapping(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("tasks_settings"),
|
||||||
|
{
|
||||||
|
"action": "source_delete",
|
||||||
|
"source_id": str(self.source.id),
|
||||||
|
"prefill_service": "whatsapp",
|
||||||
|
"prefill_identifier": "120363402761690215@g.us",
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
self.assertFalse(
|
||||||
|
ChatTaskSource.objects.filter(id=self.source.id, user=self.user).exists()
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ from django.db import transaction
|
|||||||
from django.db.models import Avg, Count, Q, Sum
|
from django.db.models import Avg, Count, Q, Sum
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views import View
|
from django.views import View
|
||||||
|
|
||||||
|
from core.commands.policies import BP_VARIANT_KEYS, BP_VARIANT_META, ensure_variant_policies_for_profile
|
||||||
from core.models import (
|
from core.models import (
|
||||||
AIRunLog,
|
AIRunLog,
|
||||||
BusinessPlanDocument,
|
BusinessPlanDocument,
|
||||||
@@ -17,12 +19,29 @@ from core.models import (
|
|||||||
CommandAction,
|
CommandAction,
|
||||||
CommandChannelBinding,
|
CommandChannelBinding,
|
||||||
CommandProfile,
|
CommandProfile,
|
||||||
|
CommandVariantPolicy,
|
||||||
TranslationBridge,
|
TranslationBridge,
|
||||||
TranslationEventLog,
|
TranslationEventLog,
|
||||||
)
|
)
|
||||||
from core.translation.engine import parse_quick_mode_title
|
from core.translation.engine import parse_quick_mode_title
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_variants(service: str, identifier: str) -> list[str]:
|
||||||
|
value = str(identifier or "").strip()
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
variants = [value]
|
||||||
|
svc = str(service or "").strip().lower()
|
||||||
|
if svc == "whatsapp":
|
||||||
|
bare = value.split("@", 1)[0].strip()
|
||||||
|
if bare and bare not in variants:
|
||||||
|
variants.append(bare)
|
||||||
|
group = f"{bare}@g.us" if bare else ""
|
||||||
|
if group and group not in variants:
|
||||||
|
variants.append(group)
|
||||||
|
return variants
|
||||||
|
|
||||||
|
|
||||||
class CommandRoutingSettings(LoginRequiredMixin, View):
|
class CommandRoutingSettings(LoginRequiredMixin, View):
|
||||||
template_name = "pages/command-routing.html"
|
template_name = "pages/command-routing.html"
|
||||||
|
|
||||||
@@ -35,12 +54,71 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
|||||||
row.save(update_fields=["position", "updated_at"])
|
row.save(update_fields=["position", "updated_at"])
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _redirect_with_scope(request):
|
||||||
|
service = str(request.GET.get("service") or request.POST.get("service") or "").strip()
|
||||||
|
identifier = str(
|
||||||
|
request.GET.get("identifier") or request.POST.get("identifier") or ""
|
||||||
|
).strip()
|
||||||
|
if service and identifier:
|
||||||
|
return redirect(
|
||||||
|
f"{reverse('command_routing')}?service={service}&identifier={identifier}"
|
||||||
|
)
|
||||||
|
return redirect("command_routing")
|
||||||
|
|
||||||
def _context(self, request):
|
def _context(self, request):
|
||||||
profiles = (
|
profiles_qs = (
|
||||||
CommandProfile.objects.filter(user=request.user)
|
CommandProfile.objects.filter(user=request.user)
|
||||||
.prefetch_related("channel_bindings", "actions")
|
.prefetch_related("channel_bindings", "actions", "variant_policies")
|
||||||
.order_by("slug")
|
.order_by("slug")
|
||||||
)
|
)
|
||||||
|
scope_service = str(request.GET.get("service") or "").strip().lower()
|
||||||
|
scope_identifier = str(request.GET.get("identifier") or "").strip()
|
||||||
|
scope_variants = _channel_variants(scope_service, scope_identifier)
|
||||||
|
profiles = list(profiles_qs)
|
||||||
|
preview_profile_id = str(request.GET.get("preview_profile_id") or "").strip()
|
||||||
|
for profile in profiles:
|
||||||
|
policies = ensure_variant_policies_for_profile(profile)
|
||||||
|
if str(profile.slug or "").strip() == "bp":
|
||||||
|
keys = BP_VARIANT_KEYS
|
||||||
|
else:
|
||||||
|
keys = ("default",)
|
||||||
|
profile.variant_rows = []
|
||||||
|
for key in keys:
|
||||||
|
row = policies.get(key)
|
||||||
|
if row is None:
|
||||||
|
continue
|
||||||
|
meta = BP_VARIANT_META.get(key, {})
|
||||||
|
profile.variant_rows.append(
|
||||||
|
{
|
||||||
|
"variant_key": key,
|
||||||
|
"variant_label": str(meta.get("name") or key),
|
||||||
|
"trigger_token": str(meta.get("trigger_token") or profile.trigger_token or ""),
|
||||||
|
"template_supported": bool(meta.get("template_supported")),
|
||||||
|
"warn_verbatim_plan": bool(
|
||||||
|
key in {"bp", "bp_set_range"}
|
||||||
|
and str(getattr(row, "generation_mode", "") or "") == "verbatim"
|
||||||
|
and bool(getattr(row, "send_plan_to_egress", False))
|
||||||
|
),
|
||||||
|
"row": row,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
bindings = list(profile.channel_bindings.all())
|
||||||
|
if scope_service and scope_variants:
|
||||||
|
profile.visible_bindings = [
|
||||||
|
row
|
||||||
|
for row in bindings
|
||||||
|
if str(row.service or "").strip().lower() == scope_service
|
||||||
|
and str(row.channel_identifier or "").strip() in scope_variants
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
profile.visible_bindings = bindings
|
||||||
|
profile.enabled_egress_bindings = [
|
||||||
|
row
|
||||||
|
for row in bindings
|
||||||
|
if str(row.direction or "").strip() == "egress" and bool(row.enabled)
|
||||||
|
]
|
||||||
|
profile.preview_mode = preview_profile_id and str(profile.id) == preview_profile_id
|
||||||
documents = BusinessPlanDocument.objects.filter(user=request.user).order_by(
|
documents = BusinessPlanDocument.objects.filter(user=request.user).order_by(
|
||||||
"-updated_at"
|
"-updated_at"
|
||||||
)[:30]
|
)[:30]
|
||||||
@@ -50,6 +128,11 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
|||||||
"channel_services": ("web", "xmpp", "signal", "whatsapp"),
|
"channel_services": ("web", "xmpp", "signal", "whatsapp"),
|
||||||
"directions": ("ingress", "egress", "scratchpad_mirror"),
|
"directions": ("ingress", "egress", "scratchpad_mirror"),
|
||||||
"action_types": ("extract_bp", "post_result", "save_document"),
|
"action_types": ("extract_bp", "post_result", "save_document"),
|
||||||
|
"command_choices": (("bp", "Business Plan (bp)"),),
|
||||||
|
"scope_service": scope_service,
|
||||||
|
"scope_identifier": scope_identifier,
|
||||||
|
"scope_variants": scope_variants,
|
||||||
|
"preview_profile_id": preview_profile_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
@@ -59,7 +142,12 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
|||||||
action = str(request.POST.get("action") or "").strip()
|
action = str(request.POST.get("action") or "").strip()
|
||||||
|
|
||||||
if action == "profile_create":
|
if action == "profile_create":
|
||||||
slug = str(request.POST.get("slug") or "bp").strip().lower() or "bp"
|
slug = (
|
||||||
|
str(request.POST.get("command_slug") or request.POST.get("slug") or "bp")
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
or "bp"
|
||||||
|
)
|
||||||
profile, _ = CommandProfile.objects.get_or_create(
|
profile, _ = CommandProfile.objects.get_or_create(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
@@ -74,6 +162,11 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
|||||||
"template_text": str(request.POST.get("template_text") or ""),
|
"template_text": str(request.POST.get("template_text") or ""),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
profile.name = str(request.POST.get("name") or profile.name).strip() or profile.name
|
||||||
|
if slug == "bp":
|
||||||
|
profile.trigger_token = "#bp#"
|
||||||
|
profile.template_text = str(request.POST.get("template_text") or profile.template_text or "")
|
||||||
|
profile.save(update_fields=["name", "trigger_token", "template_text", "updated_at"])
|
||||||
CommandAction.objects.get_or_create(
|
CommandAction.objects.get_or_create(
|
||||||
profile=profile,
|
profile=profile,
|
||||||
action_type="extract_bp",
|
action_type="extract_bp",
|
||||||
@@ -89,7 +182,8 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
|||||||
action_type="post_result",
|
action_type="post_result",
|
||||||
defaults={"enabled": True, "position": 2},
|
defaults={"enabled": True, "position": 2},
|
||||||
)
|
)
|
||||||
return redirect("command_routing")
|
ensure_variant_policies_for_profile(profile)
|
||||||
|
return self._redirect_with_scope(request)
|
||||||
|
|
||||||
if action == "profile_update":
|
if action == "profile_update":
|
||||||
profile = get_object_or_404(
|
profile = get_object_or_404(
|
||||||
@@ -106,12 +200,11 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
|||||||
profile.reply_required = bool(request.POST.get("reply_required"))
|
profile.reply_required = bool(request.POST.get("reply_required"))
|
||||||
profile.exact_match_only = bool(request.POST.get("exact_match_only"))
|
profile.exact_match_only = bool(request.POST.get("exact_match_only"))
|
||||||
profile.template_text = str(request.POST.get("template_text") or "")
|
profile.template_text = str(request.POST.get("template_text") or "")
|
||||||
profile.visibility_mode = (
|
# Legacy field retained for compatibility only.
|
||||||
str(request.POST.get("visibility_mode") or "status_in_source").strip()
|
profile.visibility_mode = profile.visibility_mode or "status_in_source"
|
||||||
or "status_in_source"
|
|
||||||
)
|
|
||||||
profile.save()
|
profile.save()
|
||||||
return redirect("command_routing")
|
ensure_variant_policies_for_profile(profile)
|
||||||
|
return self._redirect_with_scope(request)
|
||||||
|
|
||||||
if action == "profile_delete":
|
if action == "profile_delete":
|
||||||
profile = get_object_or_404(
|
profile = get_object_or_404(
|
||||||
@@ -120,7 +213,7 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
|||||||
user=request.user,
|
user=request.user,
|
||||||
)
|
)
|
||||||
profile.delete()
|
profile.delete()
|
||||||
return redirect("command_routing")
|
return self._redirect_with_scope(request)
|
||||||
|
|
||||||
if action == "binding_create":
|
if action == "binding_create":
|
||||||
profile = get_object_or_404(
|
profile = get_object_or_404(
|
||||||
@@ -137,7 +230,7 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
|||||||
).strip(),
|
).strip(),
|
||||||
enabled=bool(request.POST.get("enabled") or "1"),
|
enabled=bool(request.POST.get("enabled") or "1"),
|
||||||
)
|
)
|
||||||
return redirect("command_routing")
|
return self._redirect_with_scope(request)
|
||||||
|
|
||||||
if action == "binding_delete":
|
if action == "binding_delete":
|
||||||
binding = get_object_or_404(
|
binding = get_object_or_404(
|
||||||
@@ -146,7 +239,7 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
|||||||
profile__user=request.user,
|
profile__user=request.user,
|
||||||
)
|
)
|
||||||
binding.delete()
|
binding.delete()
|
||||||
return redirect("command_routing")
|
return self._redirect_with_scope(request)
|
||||||
|
|
||||||
if action == "action_update":
|
if action == "action_update":
|
||||||
row = get_object_or_404(
|
row = get_object_or_404(
|
||||||
@@ -160,7 +253,7 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
|||||||
row.save(update_fields=["enabled", "position", "updated_at"])
|
row.save(update_fields=["enabled", "position", "updated_at"])
|
||||||
else:
|
else:
|
||||||
row.save(update_fields=["enabled", "updated_at"])
|
row.save(update_fields=["enabled", "updated_at"])
|
||||||
return redirect("command_routing")
|
return self._redirect_with_scope(request)
|
||||||
|
|
||||||
if action == "action_move":
|
if action == "action_move":
|
||||||
row = get_object_or_404(
|
row = get_object_or_404(
|
||||||
@@ -170,26 +263,74 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
|||||||
)
|
)
|
||||||
direction = str(request.POST.get("direction") or "").strip().lower()
|
direction = str(request.POST.get("direction") or "").strip().lower()
|
||||||
if direction not in {"up", "down"}:
|
if direction not in {"up", "down"}:
|
||||||
return redirect("command_routing")
|
return self._redirect_with_scope(request)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
ordered = self._normalize_action_positions(row.profile)
|
ordered = self._normalize_action_positions(row.profile)
|
||||||
action_ids = [entry.id for entry in ordered]
|
action_ids = [entry.id for entry in ordered]
|
||||||
try:
|
try:
|
||||||
idx = action_ids.index(row.id)
|
idx = action_ids.index(row.id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return redirect("command_routing")
|
return self._redirect_with_scope(request)
|
||||||
target_idx = idx - 1 if direction == "up" else idx + 1
|
target_idx = idx - 1 if direction == "up" else idx + 1
|
||||||
if target_idx < 0 or target_idx >= len(ordered):
|
if target_idx < 0 or target_idx >= len(ordered):
|
||||||
return redirect("command_routing")
|
return self._redirect_with_scope(request)
|
||||||
other = ordered[target_idx]
|
other = ordered[target_idx]
|
||||||
current_pos = ordered[idx].position
|
current_pos = ordered[idx].position
|
||||||
ordered[idx].position = other.position
|
ordered[idx].position = other.position
|
||||||
other.position = current_pos
|
other.position = current_pos
|
||||||
ordered[idx].save(update_fields=["position", "updated_at"])
|
ordered[idx].save(update_fields=["position", "updated_at"])
|
||||||
other.save(update_fields=["position", "updated_at"])
|
other.save(update_fields=["position", "updated_at"])
|
||||||
return redirect("command_routing")
|
return self._redirect_with_scope(request)
|
||||||
|
|
||||||
return redirect("command_routing")
|
if action == "variant_policy_update":
|
||||||
|
profile = get_object_or_404(
|
||||||
|
CommandProfile,
|
||||||
|
id=request.POST.get("profile_id"),
|
||||||
|
user=request.user,
|
||||||
|
)
|
||||||
|
variant_key = str(request.POST.get("variant_key") or "").strip()
|
||||||
|
policy = get_object_or_404(
|
||||||
|
CommandVariantPolicy,
|
||||||
|
profile=profile,
|
||||||
|
variant_key=variant_key,
|
||||||
|
)
|
||||||
|
policy.enabled = bool(request.POST.get("enabled"))
|
||||||
|
mode = str(request.POST.get("generation_mode") or "verbatim").strip().lower()
|
||||||
|
policy.generation_mode = mode if mode in {"ai", "verbatim"} else "verbatim"
|
||||||
|
policy.send_plan_to_egress = bool(request.POST.get("send_plan_to_egress"))
|
||||||
|
policy.send_status_to_source = bool(request.POST.get("send_status_to_source"))
|
||||||
|
policy.send_status_to_egress = bool(request.POST.get("send_status_to_egress"))
|
||||||
|
policy.store_document = bool(request.POST.get("store_document"))
|
||||||
|
policy.save()
|
||||||
|
return self._redirect_with_scope(request)
|
||||||
|
|
||||||
|
if action == "variant_policy_reset_defaults":
|
||||||
|
profile = get_object_or_404(
|
||||||
|
CommandProfile,
|
||||||
|
id=request.POST.get("profile_id"),
|
||||||
|
user=request.user,
|
||||||
|
)
|
||||||
|
profile.variant_policies.all().delete()
|
||||||
|
ensure_variant_policies_for_profile(profile)
|
||||||
|
return self._redirect_with_scope(request)
|
||||||
|
|
||||||
|
if action == "variant_preview":
|
||||||
|
profile = get_object_or_404(
|
||||||
|
CommandProfile,
|
||||||
|
id=request.POST.get("profile_id"),
|
||||||
|
user=request.user,
|
||||||
|
)
|
||||||
|
ensure_variant_policies_for_profile(profile)
|
||||||
|
service = str(request.GET.get("service") or request.POST.get("service") or "").strip()
|
||||||
|
identifier = str(
|
||||||
|
request.GET.get("identifier") or request.POST.get("identifier") or ""
|
||||||
|
).strip()
|
||||||
|
query = f"?preview_profile_id={profile.id}"
|
||||||
|
if service and identifier:
|
||||||
|
query += f"&service={service}&identifier={identifier}"
|
||||||
|
return redirect(f"{reverse('command_routing')}{query}")
|
||||||
|
|
||||||
|
return self._redirect_with_scope(request)
|
||||||
|
|
||||||
|
|
||||||
class TranslationSettings(LoginRequiredMixin, View):
|
class TranslationSettings(LoginRequiredMixin, View):
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from django.views import View
|
|||||||
from core.clients import transport
|
from core.clients import transport
|
||||||
from core.commands.base import CommandContext
|
from core.commands.base import CommandContext
|
||||||
from core.commands.engine import process_inbound_message
|
from core.commands.engine import process_inbound_message
|
||||||
|
from core.commands.policies import ensure_variant_policies_for_profile
|
||||||
from core.messaging import ai as ai_runner
|
from core.messaging import ai as ai_runner
|
||||||
from core.messaging import media_bridge
|
from core.messaging import media_bridge
|
||||||
from core.messaging.utils import messages_to_string
|
from core.messaging.utils import messages_to_string
|
||||||
@@ -1610,6 +1611,29 @@ def _latest_whatsapp_bridge_ref(message: Message | None) -> dict:
|
|||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_signal_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("signal") or [])
|
||||||
|
best = {}
|
||||||
|
best_updated = -1
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
has_upstream = str(row.get("upstream_message_id") or "").strip() or int(
|
||||||
|
row.get("upstream_ts") or 0
|
||||||
|
)
|
||||||
|
if not has_upstream:
|
||||||
|
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:
|
def _build_whatsapp_reply_metadata(reply_to: Message | None, channel_identifier: str) -> dict:
|
||||||
if reply_to is None:
|
if reply_to is None:
|
||||||
return {}
|
return {}
|
||||||
@@ -1640,6 +1664,58 @@ def _build_whatsapp_reply_metadata(reply_to: Message | None, channel_identifier:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_signal_reply_metadata(reply_to: Message | None, channel_identifier: str) -> dict:
|
||||||
|
if reply_to is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
quote_timestamp = 0
|
||||||
|
source_message_id = str(getattr(reply_to, "source_message_id", "") or "").strip()
|
||||||
|
if source_message_id.isdigit():
|
||||||
|
quote_timestamp = int(source_message_id)
|
||||||
|
if not quote_timestamp:
|
||||||
|
bridge_ref = _latest_signal_bridge_ref(reply_to)
|
||||||
|
upstream_id = str(bridge_ref.get("upstream_message_id") or "").strip()
|
||||||
|
if upstream_id.isdigit():
|
||||||
|
quote_timestamp = int(upstream_id)
|
||||||
|
if not quote_timestamp:
|
||||||
|
quote_timestamp = int(bridge_ref.get("upstream_ts") or 0)
|
||||||
|
if not quote_timestamp:
|
||||||
|
quote_timestamp = int(getattr(reply_to, "ts", 0) or 0)
|
||||||
|
if quote_timestamp <= 0:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
quote_author = ""
|
||||||
|
sender_uuid = str(getattr(reply_to, "sender_uuid", "") or "").strip()
|
||||||
|
if sender_uuid:
|
||||||
|
quote_author = sender_uuid
|
||||||
|
# Signal quote payloads work best with phone-style identifiers.
|
||||||
|
# Inbound rows may store sender UUID; prefer known chat/number in that case.
|
||||||
|
source_chat_id = str(getattr(reply_to, "source_chat_id", "") or "").strip()
|
||||||
|
if quote_author and SIGNAL_UUID_PATTERN.match(quote_author):
|
||||||
|
if source_chat_id:
|
||||||
|
quote_author = source_chat_id
|
||||||
|
if (
|
||||||
|
str(getattr(reply_to, "custom_author", "") or "").strip().upper() in {"USER", "BOT"}
|
||||||
|
or not quote_author
|
||||||
|
):
|
||||||
|
quote_author = str(getattr(settings, "SIGNAL_NUMBER", "") or "").strip() or quote_author
|
||||||
|
if not quote_author:
|
||||||
|
quote_author = source_chat_id
|
||||||
|
if not quote_author:
|
||||||
|
quote_author = str(channel_identifier or "").strip()
|
||||||
|
if not quote_author:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
quote_text = str(getattr(reply_to, "text", "") or "").strip()
|
||||||
|
payload = {
|
||||||
|
"quote_timestamp": int(quote_timestamp),
|
||||||
|
"quote_author": quote_author,
|
||||||
|
}
|
||||||
|
if quote_text:
|
||||||
|
payload["quote_text"] = quote_text[:512]
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _canonical_command_channel_identifier(service: str, identifier: str) -> str:
|
def _canonical_command_channel_identifier(service: str, identifier: str) -> str:
|
||||||
value = str(identifier or "").strip()
|
value = str(identifier or "").strip()
|
||||||
if not value:
|
if not value:
|
||||||
@@ -1689,6 +1765,7 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
|
|||||||
if (not created) and (not row.enabled):
|
if (not created) and (not row.enabled):
|
||||||
row.enabled = True
|
row.enabled = True
|
||||||
row.save(update_fields=["enabled", "updated_at"])
|
row.save(update_fields=["enabled", "updated_at"])
|
||||||
|
ensure_variant_policies_for_profile(profile)
|
||||||
return profile
|
return profile
|
||||||
|
|
||||||
|
|
||||||
@@ -1779,46 +1856,102 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
|
|||||||
channel_identifier__in=list(variants),
|
channel_identifier__in=list(variants),
|
||||||
enabled=True,
|
enabled=True,
|
||||||
).exists()
|
).exists()
|
||||||
|
if slug == "bp":
|
||||||
|
policies = ensure_variant_policies_for_profile(profile) if profile.id else {}
|
||||||
|
label_by_key = {
|
||||||
|
"bp": "bp",
|
||||||
|
"bp_set": "bp set",
|
||||||
|
"bp_set_range": "bp set range",
|
||||||
|
}
|
||||||
|
options.extend(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"slug": "bp",
|
||||||
|
"toggle_slug": "bp",
|
||||||
|
"name": "bp",
|
||||||
|
"trigger_token": "#bp#",
|
||||||
|
"enabled_here": bool(enabled_here),
|
||||||
|
"profile_enabled": bool(profile.enabled),
|
||||||
|
"mode_label": str(
|
||||||
|
(policies.get("bp").generation_mode if policies.get("bp") else "ai")
|
||||||
|
).upper(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "bp_set",
|
||||||
|
"toggle_slug": "bp",
|
||||||
|
"name": "bp set",
|
||||||
|
"trigger_token": "#bp set#",
|
||||||
|
"enabled_here": bool(enabled_here),
|
||||||
|
"profile_enabled": bool(profile.enabled),
|
||||||
|
"mode_label": str(
|
||||||
|
(policies.get("bp_set").generation_mode if policies.get("bp_set") else "verbatim")
|
||||||
|
).upper(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "bp_set_range",
|
||||||
|
"toggle_slug": "bp",
|
||||||
|
"name": "bp set range",
|
||||||
|
"trigger_token": "#bp set range#",
|
||||||
|
"enabled_here": bool(enabled_here),
|
||||||
|
"profile_enabled": bool(profile.enabled),
|
||||||
|
"mode_label": str(
|
||||||
|
(
|
||||||
|
policies.get("bp_set_range").generation_mode
|
||||||
|
if policies.get("bp_set_range")
|
||||||
|
else "verbatim"
|
||||||
|
)
|
||||||
|
).upper(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for row in options:
|
||||||
|
if row.get("slug") in label_by_key:
|
||||||
|
row["enabled_label"] = "Enabled" if row.get("enabled_here") else "Disabled"
|
||||||
|
else:
|
||||||
options.append(
|
options.append(
|
||||||
{
|
{
|
||||||
"slug": slug,
|
"slug": slug,
|
||||||
|
"toggle_slug": slug,
|
||||||
"name": str(profile.name or slug).strip() or slug,
|
"name": str(profile.name or slug).strip() or slug,
|
||||||
"trigger_token": str(profile.trigger_token or "").strip(),
|
"trigger_token": str(profile.trigger_token or "").strip(),
|
||||||
"enabled_here": bool(enabled_here),
|
"enabled_here": bool(enabled_here),
|
||||||
"profile_enabled": bool(profile.enabled),
|
"profile_enabled": bool(profile.enabled),
|
||||||
}
|
"mode_label": "",
|
||||||
)
|
"enabled_label": "Enabled" if enabled_here else "Disabled",
|
||||||
task_announce_enabled = False
|
|
||||||
if variants:
|
|
||||||
source = (
|
|
||||||
ChatTaskSource.objects.filter(
|
|
||||||
user=user,
|
|
||||||
service=service_key,
|
|
||||||
channel_identifier__in=list(variants),
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
.order_by("-updated_at")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
settings_row = dict(getattr(source, "settings", {}) or {}) if source else {}
|
|
||||||
task_announce_enabled = str(settings_row.get("announce_task_id", "")).strip().lower() in {
|
|
||||||
"1",
|
|
||||||
"true",
|
|
||||||
"yes",
|
|
||||||
"on",
|
|
||||||
}
|
|
||||||
options.append(
|
|
||||||
{
|
|
||||||
"slug": "task_announce",
|
|
||||||
"name": "Announce Task IDs",
|
|
||||||
"trigger_token": "",
|
|
||||||
"enabled_here": bool(task_announce_enabled),
|
|
||||||
"profile_enabled": True,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def _bp_binding_summary_for_channel(user, service: str, identifier: str) -> dict:
|
||||||
|
service_key = _default_service(service)
|
||||||
|
variants = _command_channel_identifier_variants(service_key, identifier)
|
||||||
|
if not variants:
|
||||||
|
return {"ingress_count": 0, "egress_count": 0}
|
||||||
|
profile = (
|
||||||
|
CommandProfile.objects.filter(user=user, slug="bp")
|
||||||
|
.order_by("id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if profile is None:
|
||||||
|
return {"ingress_count": 0, "egress_count": 0}
|
||||||
|
ingress_count = CommandChannelBinding.objects.filter(
|
||||||
|
profile=profile,
|
||||||
|
direction="ingress",
|
||||||
|
service=service_key,
|
||||||
|
channel_identifier__in=list(variants),
|
||||||
|
enabled=True,
|
||||||
|
).count()
|
||||||
|
egress_count = CommandChannelBinding.objects.filter(
|
||||||
|
profile=profile,
|
||||||
|
direction="egress",
|
||||||
|
service=service_key,
|
||||||
|
channel_identifier__in=list(variants),
|
||||||
|
enabled=True,
|
||||||
|
).count()
|
||||||
|
return {"ingress_count": int(ingress_count), "egress_count": int(egress_count)}
|
||||||
|
|
||||||
|
|
||||||
def _toggle_task_announce_for_channel(
|
def _toggle_task_announce_for_channel(
|
||||||
*,
|
*,
|
||||||
user,
|
user,
|
||||||
@@ -2450,6 +2583,11 @@ def _panel_context(
|
|||||||
base["service"],
|
base["service"],
|
||||||
base["identifier"],
|
base["identifier"],
|
||||||
)
|
)
|
||||||
|
bp_binding_summary = _bp_binding_summary_for_channel(
|
||||||
|
request.user,
|
||||||
|
base["service"],
|
||||||
|
base["identifier"],
|
||||||
|
)
|
||||||
recent_contacts = _recent_manual_contacts(
|
recent_contacts = _recent_manual_contacts(
|
||||||
request.user,
|
request.user,
|
||||||
current_service=base["service"],
|
current_service=base["service"],
|
||||||
@@ -2457,6 +2595,27 @@ def _panel_context(
|
|||||||
current_person=base["person"],
|
current_person=base["person"],
|
||||||
limit=12,
|
limit=12,
|
||||||
)
|
)
|
||||||
|
signal_ingest_warning = ""
|
||||||
|
if base["service"] == "signal":
|
||||||
|
signal_state = transport.get_runtime_state("signal") or {}
|
||||||
|
error_type = str(signal_state.get("last_inbound_exception_type") or "").strip()
|
||||||
|
error_message = str(
|
||||||
|
signal_state.get("last_inbound_exception_message") or ""
|
||||||
|
).strip()
|
||||||
|
try:
|
||||||
|
error_ts = int(signal_state.get("last_inbound_exception_ts") or 0)
|
||||||
|
except Exception:
|
||||||
|
error_ts = 0
|
||||||
|
try:
|
||||||
|
ok_ts = int(signal_state.get("last_inbound_ok_ts") or 0)
|
||||||
|
except Exception:
|
||||||
|
ok_ts = 0
|
||||||
|
if (error_type or error_message) and error_ts >= ok_ts:
|
||||||
|
signal_ingest_warning = (
|
||||||
|
"Signal inbound decrypt/metadata error detected"
|
||||||
|
+ (f" ({error_type})" if error_type else "")
|
||||||
|
+ (f": {error_message[:220]}" if error_message else "")
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"service": base["service"],
|
"service": base["service"],
|
||||||
@@ -2485,6 +2644,9 @@ def _panel_context(
|
|||||||
"compose_quick_insights_url": reverse("compose_quick_insights"),
|
"compose_quick_insights_url": reverse("compose_quick_insights"),
|
||||||
"compose_history_sync_url": reverse("compose_history_sync"),
|
"compose_history_sync_url": reverse("compose_history_sync"),
|
||||||
"compose_toggle_command_url": reverse("compose_toggle_command"),
|
"compose_toggle_command_url": reverse("compose_toggle_command"),
|
||||||
|
"command_routing_scoped_url": (
|
||||||
|
f"{reverse('command_routing')}?{urlencode({'service': base['service'], 'identifier': base['identifier'] or ''})}"
|
||||||
|
),
|
||||||
"compose_answer_suggestion_send_url": reverse("compose_answer_suggestion_send"),
|
"compose_answer_suggestion_send_url": reverse("compose_answer_suggestion_send"),
|
||||||
"compose_ws_url": ws_url,
|
"compose_ws_url": ws_url,
|
||||||
"tasks_hub_url": reverse("tasks_hub"),
|
"tasks_hub_url": reverse("tasks_hub"),
|
||||||
@@ -2515,8 +2677,10 @@ def _panel_context(
|
|||||||
"panel_id": f"compose-panel-{unique}",
|
"panel_id": f"compose-panel-{unique}",
|
||||||
"typing_state_json": json.dumps(typing_state),
|
"typing_state_json": json.dumps(typing_state),
|
||||||
"command_options": command_options,
|
"command_options": command_options,
|
||||||
|
"bp_binding_summary": bp_binding_summary,
|
||||||
"platform_options": platform_options,
|
"platform_options": platform_options,
|
||||||
"recent_contacts": recent_contacts,
|
"recent_contacts": recent_contacts,
|
||||||
|
"signal_ingest_warning": signal_ingest_warning,
|
||||||
"is_group": base.get("is_group", False),
|
"is_group": base.get("is_group", False),
|
||||||
"group_name": base.get("group_name", ""),
|
"group_name": base.get("group_name", ""),
|
||||||
}
|
}
|
||||||
@@ -3366,6 +3530,8 @@ class ComposeToggleCommand(LoginRequiredMixin, View):
|
|||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
slug = str(request.POST.get("slug") or "bp").strip().lower() or "bp"
|
slug = str(request.POST.get("slug") or "bp").strip().lower() or "bp"
|
||||||
|
if slug in {"bp_set", "bp_set_range"}:
|
||||||
|
slug = "bp"
|
||||||
enabled = str(request.POST.get("enabled") or "1").strip().lower() in {
|
enabled = str(request.POST.get("enabled") or "1").strip().lower() in {
|
||||||
"1",
|
"1",
|
||||||
"true",
|
"true",
|
||||||
@@ -3406,6 +3572,9 @@ class ComposeToggleCommand(LoginRequiredMixin, View):
|
|||||||
if enabled
|
if enabled
|
||||||
else f"{slug} disabled for this chat."
|
else f"{slug} disabled for this chat."
|
||||||
)
|
)
|
||||||
|
scoped_settings_url = (
|
||||||
|
f"{reverse('command_routing')}?{urlencode({'service': service, 'identifier': channel_identifier})}"
|
||||||
|
)
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -3416,7 +3585,7 @@ class ComposeToggleCommand(LoginRequiredMixin, View):
|
|||||||
"settings_url": (
|
"settings_url": (
|
||||||
f"{reverse('tasks_settings')}?{urlencode({'service': service, 'identifier': channel_identifier})}"
|
f"{reverse('tasks_settings')}?{urlencode({'service': service, 'identifier': channel_identifier})}"
|
||||||
if slug == "task_announce"
|
if slug == "task_announce"
|
||||||
else reverse("command_routing")
|
else scoped_settings_url
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -3442,7 +3611,7 @@ class ComposeBindBP(ComposeToggleCommand):
|
|||||||
"message": "bp enabled for this chat.",
|
"message": "bp enabled for this chat.",
|
||||||
"slug": "bp",
|
"slug": "bp",
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"settings_url": reverse("command_routing"),
|
"settings_url": f"{reverse('command_routing')}?{urlencode({'service': service, 'identifier': str(identifier or '')})}",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -4026,6 +4195,10 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
outbound_reply_metadata = _build_whatsapp_reply_metadata(
|
outbound_reply_metadata = _build_whatsapp_reply_metadata(
|
||||||
reply_to, str(base["identifier"] or "")
|
reply_to, str(base["identifier"] or "")
|
||||||
)
|
)
|
||||||
|
elif base["service"] == "signal":
|
||||||
|
outbound_reply_metadata = _build_signal_reply_metadata(
|
||||||
|
reply_to, str(base["identifier"] or "")
|
||||||
|
)
|
||||||
if base["service"] == "whatsapp":
|
if base["service"] == "whatsapp":
|
||||||
runtime_state = transport.get_runtime_state("whatsapp")
|
runtime_state = transport.get_runtime_state("whatsapp")
|
||||||
last_seen = int(runtime_state.get("runtime_seen_at") or 0)
|
last_seen = int(runtime_state.get("runtime_seen_at") or 0)
|
||||||
@@ -4102,6 +4275,10 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
outbound_reply_metadata = _build_whatsapp_reply_metadata(
|
outbound_reply_metadata = _build_whatsapp_reply_metadata(
|
||||||
reply_to, str(base["identifier"] or "")
|
reply_to, str(base["identifier"] or "")
|
||||||
)
|
)
|
||||||
|
elif base["service"] == "signal":
|
||||||
|
outbound_reply_metadata = _build_signal_reply_metadata(
|
||||||
|
reply_to, str(base["identifier"] or "")
|
||||||
|
)
|
||||||
ts = async_to_sync(transport.send_message_raw)(
|
ts = async_to_sync(transport.send_message_raw)(
|
||||||
base["service"],
|
base["service"],
|
||||||
base["identifier"],
|
base["identifier"],
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from urllib.parse import urlencode
|
|||||||
import orjson
|
import orjson
|
||||||
import requests
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.db.models import Q
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.views import View
|
from django.views import View
|
||||||
@@ -68,6 +70,11 @@ class SignalAccounts(SuperUserRequiredMixin, ObjectList):
|
|||||||
"service": service,
|
"service": service,
|
||||||
"service_label": label,
|
"service_label": label,
|
||||||
"account_add_url_name": add_url_name,
|
"account_add_url_name": add_url_name,
|
||||||
|
"account_add_type": "modal",
|
||||||
|
"account_add_target": "#modals-here",
|
||||||
|
"account_add_swap": "innerHTML",
|
||||||
|
"account_unlink_url_name": "signal_account_unlink",
|
||||||
|
"account_unlink_label": "Relink",
|
||||||
"show_contact_actions": show_contact_actions,
|
"show_contact_actions": show_contact_actions,
|
||||||
"contacts_url_name": f"{service}_contacts",
|
"contacts_url_name": f"{service}_contacts",
|
||||||
"chats_url_name": f"{service}_chats",
|
"chats_url_name": f"{service}_chats",
|
||||||
@@ -89,6 +96,69 @@ class SignalAccounts(SuperUserRequiredMixin, ObjectList):
|
|||||||
return self._normalize_accounts(transport.list_accounts("signal"))
|
return self._normalize_accounts(transport.list_accounts("signal"))
|
||||||
|
|
||||||
|
|
||||||
|
class SignalAccountUnlink(SuperUserRequiredMixin, View):
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
return self.delete(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def delete(self, request, *args, **kwargs):
|
||||||
|
account = str(kwargs.get("account") or "").strip()
|
||||||
|
if account:
|
||||||
|
ok = transport.unlink_account("signal", account)
|
||||||
|
if ok:
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
(
|
||||||
|
"Signal account unlinked. Next step: enter a device name under "
|
||||||
|
"'Add account', submit, then scan the new QR code."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
"Signal relink failed to clear current device state. Try relink again.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.warning(request, "No Signal account selected to relink.")
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for item in transport.list_accounts("signal"):
|
||||||
|
if isinstance(item, dict):
|
||||||
|
value = (
|
||||||
|
item.get("number")
|
||||||
|
or item.get("id")
|
||||||
|
or item.get("jid")
|
||||||
|
or item.get("account")
|
||||||
|
)
|
||||||
|
if value:
|
||||||
|
rows.append(str(value))
|
||||||
|
elif item:
|
||||||
|
rows.append(str(item))
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"service": "signal",
|
||||||
|
"service_label": "Signal",
|
||||||
|
"account_add_url_name": "signal_account_add",
|
||||||
|
"account_add_type": "modal",
|
||||||
|
"account_add_target": "#modals-here",
|
||||||
|
"account_add_swap": "innerHTML",
|
||||||
|
"account_unlink_url_name": "signal_account_unlink",
|
||||||
|
"account_unlink_label": "Relink",
|
||||||
|
"show_contact_actions": True,
|
||||||
|
"contacts_url_name": "signal_contacts",
|
||||||
|
"chats_url_name": "signal_chats",
|
||||||
|
"endpoint_base": str(
|
||||||
|
getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")
|
||||||
|
).rstrip("/"),
|
||||||
|
"service_warning": transport.get_service_warning("signal"),
|
||||||
|
"object_list": rows,
|
||||||
|
"list_url": reverse("signal_accounts", kwargs={"type": kwargs["type"]}),
|
||||||
|
"type": kwargs["type"],
|
||||||
|
"context_object_name_singular": "Signal Account",
|
||||||
|
"context_object_name": "Signal Accounts",
|
||||||
|
}
|
||||||
|
return render(request, "partials/signal-accounts.html", context)
|
||||||
|
|
||||||
|
|
||||||
class SignalContactsList(SuperUserRequiredMixin, ObjectList):
|
class SignalContactsList(SuperUserRequiredMixin, ObjectList):
|
||||||
list_template = "partials/signal-contacts-list.html"
|
list_template = "partials/signal-contacts-list.html"
|
||||||
|
|
||||||
@@ -141,7 +211,13 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
|
|||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
pk = self.kwargs.get("pk", "")
|
pk = self.kwargs.get("pk", "")
|
||||||
chats = list(Chat.objects.filter(account=pk))
|
chats = list(
|
||||||
|
Chat.objects.filter(
|
||||||
|
Q(account=pk) | Q(account__isnull=True) | Q(account="")
|
||||||
|
).order_by("-id")[:1000]
|
||||||
|
)
|
||||||
|
if not chats:
|
||||||
|
chats = list(Chat.objects.all().order_by("-id")[:1000])
|
||||||
rows = []
|
rows = []
|
||||||
for chat in chats:
|
for chat in chats:
|
||||||
identifier_candidates = [
|
identifier_candidates = [
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from core.models import (
|
|||||||
TaskProviderConfig,
|
TaskProviderConfig,
|
||||||
PersonIdentifier,
|
PersonIdentifier,
|
||||||
PlatformChatLink,
|
PlatformChatLink,
|
||||||
|
Chat,
|
||||||
)
|
)
|
||||||
from core.tasks.providers.mock import get_provider
|
from core.tasks.providers.mock import get_provider
|
||||||
|
|
||||||
@@ -138,6 +139,25 @@ def _settings_redirect(request):
|
|||||||
return redirect("tasks_settings")
|
return redirect("tasks_settings")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_default_completion_patterns(user) -> None:
|
||||||
|
defaults = ("done", "completed", "fixed")
|
||||||
|
existing = set(
|
||||||
|
str(row or "").strip().lower()
|
||||||
|
for row in TaskCompletionPattern.objects.filter(user=user).values_list("phrase", flat=True)
|
||||||
|
)
|
||||||
|
next_pos = TaskCompletionPattern.objects.filter(user=user).count()
|
||||||
|
for phrase in defaults:
|
||||||
|
if phrase in existing:
|
||||||
|
continue
|
||||||
|
TaskCompletionPattern.objects.create(
|
||||||
|
user=user,
|
||||||
|
phrase=phrase,
|
||||||
|
enabled=True,
|
||||||
|
position=next_pos,
|
||||||
|
)
|
||||||
|
next_pos += 1
|
||||||
|
|
||||||
|
|
||||||
def _service_label(service: str) -> str:
|
def _service_label(service: str) -> str:
|
||||||
key = str(service or "").strip().lower()
|
key = str(service or "").strip().lower()
|
||||||
labels = {
|
labels = {
|
||||||
@@ -158,9 +178,52 @@ def _resolve_channel_display(user, service: str, identifier: str) -> dict:
|
|||||||
if bare_identifier and bare_identifier not in variants:
|
if bare_identifier and bare_identifier not in variants:
|
||||||
variants.append(bare_identifier)
|
variants.append(bare_identifier)
|
||||||
if service_key == "whatsapp":
|
if service_key == "whatsapp":
|
||||||
|
direct_identifier = (
|
||||||
|
raw_identifier if raw_identifier.endswith("@s.whatsapp.net") else ""
|
||||||
|
)
|
||||||
|
if direct_identifier and direct_identifier not in variants:
|
||||||
|
variants.append(direct_identifier)
|
||||||
|
if bare_identifier:
|
||||||
|
direct_bare = f"{bare_identifier}@s.whatsapp.net"
|
||||||
|
if direct_bare not in variants:
|
||||||
|
variants.append(direct_bare)
|
||||||
group_identifier = f"{bare_identifier}@g.us" if bare_identifier else ""
|
group_identifier = f"{bare_identifier}@g.us" if bare_identifier else ""
|
||||||
if group_identifier and group_identifier not in variants:
|
if group_identifier and group_identifier not in variants:
|
||||||
variants.append(group_identifier)
|
variants.append(group_identifier)
|
||||||
|
if service_key == "signal":
|
||||||
|
digits = "".join(ch for ch in raw_identifier if ch.isdigit())
|
||||||
|
if digits and digits not in variants:
|
||||||
|
variants.append(digits)
|
||||||
|
if digits:
|
||||||
|
plus = f"+{digits}"
|
||||||
|
if plus not in variants:
|
||||||
|
variants.append(plus)
|
||||||
|
if raw_identifier:
|
||||||
|
companion_numbers = list(
|
||||||
|
Chat.objects.filter(source_uuid=raw_identifier)
|
||||||
|
.exclude(source_number__isnull=True)
|
||||||
|
.exclude(source_number="")
|
||||||
|
.values_list("source_number", flat=True)[:200]
|
||||||
|
)
|
||||||
|
companion_uuids = list(
|
||||||
|
Chat.objects.filter(source_number=raw_identifier)
|
||||||
|
.exclude(source_uuid__isnull=True)
|
||||||
|
.exclude(source_uuid="")
|
||||||
|
.values_list("source_uuid", flat=True)[:200]
|
||||||
|
)
|
||||||
|
for candidate in companion_numbers + companion_uuids:
|
||||||
|
candidate_str = str(candidate or "").strip()
|
||||||
|
if not candidate_str:
|
||||||
|
continue
|
||||||
|
if candidate_str not in variants:
|
||||||
|
variants.append(candidate_str)
|
||||||
|
candidate_digits = "".join(ch for ch in candidate_str if ch.isdigit())
|
||||||
|
if candidate_digits and candidate_digits not in variants:
|
||||||
|
variants.append(candidate_digits)
|
||||||
|
if candidate_digits:
|
||||||
|
plus_variant = f"+{candidate_digits}"
|
||||||
|
if plus_variant not in variants:
|
||||||
|
variants.append(plus_variant)
|
||||||
|
|
||||||
group_link = None
|
group_link = None
|
||||||
if bare_identifier:
|
if bare_identifier:
|
||||||
@@ -200,9 +263,6 @@ def _resolve_channel_display(user, service: str, identifier: str) -> dict:
|
|||||||
str(group_link.chat_jid or "").strip()
|
str(group_link.chat_jid or "").strip()
|
||||||
or (f"{bare_identifier}@g.us" if bare_identifier else raw_identifier)
|
or (f"{bare_identifier}@g.us" if bare_identifier else raw_identifier)
|
||||||
)
|
)
|
||||||
elif service_key == "whatsapp" and bare_identifier and not raw_identifier.endswith("@g.us"):
|
|
||||||
display_identifier = f"{bare_identifier}@g.us"
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"service_key": service_key,
|
"service_key": service_key,
|
||||||
"service_label": _service_label(service_key),
|
"service_label": _service_label(service_key),
|
||||||
@@ -275,15 +335,18 @@ class TaskGroupDetail(LoginRequiredMixin, View):
|
|||||||
def get(self, request, service, identifier):
|
def get(self, request, service, identifier):
|
||||||
channel = _resolve_channel_display(request.user, service, identifier)
|
channel = _resolve_channel_display(request.user, service, identifier)
|
||||||
variants = list(channel.get("variants") or [str(identifier or "").strip()])
|
variants = list(channel.get("variants") or [str(identifier or "").strip()])
|
||||||
|
service_keys = [channel["service_key"]]
|
||||||
|
if channel["service_key"] != "web":
|
||||||
|
service_keys.append("web")
|
||||||
mappings = ChatTaskSource.objects.filter(
|
mappings = ChatTaskSource.objects.filter(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
service=channel["service_key"],
|
service__in=service_keys,
|
||||||
channel_identifier__in=variants,
|
channel_identifier__in=variants,
|
||||||
).select_related("project", "epic")
|
).select_related("project", "epic")
|
||||||
tasks = (
|
tasks = (
|
||||||
DerivedTask.objects.filter(
|
DerivedTask.objects.filter(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
source_service=channel["service_key"],
|
source_service__in=service_keys,
|
||||||
source_channel__in=variants,
|
source_channel__in=variants,
|
||||||
)
|
)
|
||||||
.select_related("project", "epic")
|
.select_related("project", "epic")
|
||||||
@@ -330,6 +393,7 @@ class TaskSettings(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
def _context(self, request):
|
def _context(self, request):
|
||||||
_apply_safe_defaults_for_user(request.user)
|
_apply_safe_defaults_for_user(request.user)
|
||||||
|
_ensure_default_completion_patterns(request.user)
|
||||||
prefill_service = str(request.GET.get("service") or "").strip().lower()
|
prefill_service = str(request.GET.get("service") or "").strip().lower()
|
||||||
prefill_identifier = str(request.GET.get("identifier") or "").strip()
|
prefill_identifier = str(request.GET.get("identifier") or "").strip()
|
||||||
projects = list(TaskProject.objects.filter(user=request.user).order_by("name"))
|
projects = list(TaskProject.objects.filter(user=request.user).order_by("name"))
|
||||||
@@ -446,6 +510,15 @@ class TaskSettings(LoginRequiredMixin, View):
|
|||||||
source.save(update_fields=["settings", "updated_at"])
|
source.save(update_fields=["settings", "updated_at"])
|
||||||
return _settings_redirect(request)
|
return _settings_redirect(request)
|
||||||
|
|
||||||
|
if action == "source_delete":
|
||||||
|
source = get_object_or_404(
|
||||||
|
ChatTaskSource,
|
||||||
|
id=request.POST.get("source_id"),
|
||||||
|
user=request.user,
|
||||||
|
)
|
||||||
|
source.delete()
|
||||||
|
return _settings_redirect(request)
|
||||||
|
|
||||||
if action == "pattern_create":
|
if action == "pattern_create":
|
||||||
phrase = str(request.POST.get("phrase") or "").strip()
|
phrase = str(request.POST.get("phrase") or "").strip()
|
||||||
if phrase:
|
if phrase:
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ class WhatsAppAccounts(SuperUserRequiredMixin, ObjectList):
|
|||||||
|
|
||||||
|
|
||||||
class WhatsAppAccountUnlink(SuperUserRequiredMixin, View):
|
class WhatsAppAccountUnlink(SuperUserRequiredMixin, View):
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
return self.delete(request, *args, **kwargs)
|
||||||
|
|
||||||
def delete(self, request, *args, **kwargs):
|
def delete(self, request, *args, **kwargs):
|
||||||
account = str(kwargs.get("account") or "").strip()
|
account = str(kwargs.get("account") or "").strip()
|
||||||
_ = transport.unlink_account("whatsapp", account)
|
_ = transport.unlink_account("whatsapp", account)
|
||||||
|
|||||||
Reference in New Issue
Block a user