Fix Signal messages and replies

This commit is contained in:
2026-03-03 15:51:58 +00:00
parent 56c620473f
commit d6bd56dace
31 changed files with 3317 additions and 668 deletions

View File

@@ -7,9 +7,11 @@ from django.db import transaction
from django.db.models import Avg, Count, Q, Sum
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
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 (
AIRunLog,
BusinessPlanDocument,
@@ -17,12 +19,29 @@ from core.models import (
CommandAction,
CommandChannelBinding,
CommandProfile,
CommandVariantPolicy,
TranslationBridge,
TranslationEventLog,
)
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):
template_name = "pages/command-routing.html"
@@ -35,12 +54,71 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
row.save(update_fields=["position", "updated_at"])
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):
profiles = (
profiles_qs = (
CommandProfile.objects.filter(user=request.user)
.prefetch_related("channel_bindings", "actions")
.prefetch_related("channel_bindings", "actions", "variant_policies")
.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(
"-updated_at"
)[:30]
@@ -50,6 +128,11 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
"channel_services": ("web", "xmpp", "signal", "whatsapp"),
"directions": ("ingress", "egress", "scratchpad_mirror"),
"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):
@@ -59,7 +142,12 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
action = str(request.POST.get("action") or "").strip()
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(
user=request.user,
slug=slug,
@@ -74,6 +162,11 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
"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(
profile=profile,
action_type="extract_bp",
@@ -89,7 +182,8 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
action_type="post_result",
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":
profile = get_object_or_404(
@@ -106,12 +200,11 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
profile.reply_required = bool(request.POST.get("reply_required"))
profile.exact_match_only = bool(request.POST.get("exact_match_only"))
profile.template_text = str(request.POST.get("template_text") or "")
profile.visibility_mode = (
str(request.POST.get("visibility_mode") or "status_in_source").strip()
or "status_in_source"
)
# Legacy field retained for compatibility only.
profile.visibility_mode = profile.visibility_mode or "status_in_source"
profile.save()
return redirect("command_routing")
ensure_variant_policies_for_profile(profile)
return self._redirect_with_scope(request)
if action == "profile_delete":
profile = get_object_or_404(
@@ -120,7 +213,7 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
user=request.user,
)
profile.delete()
return redirect("command_routing")
return self._redirect_with_scope(request)
if action == "binding_create":
profile = get_object_or_404(
@@ -137,7 +230,7 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
).strip(),
enabled=bool(request.POST.get("enabled") or "1"),
)
return redirect("command_routing")
return self._redirect_with_scope(request)
if action == "binding_delete":
binding = get_object_or_404(
@@ -146,7 +239,7 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
profile__user=request.user,
)
binding.delete()
return redirect("command_routing")
return self._redirect_with_scope(request)
if action == "action_update":
row = get_object_or_404(
@@ -160,7 +253,7 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
row.save(update_fields=["enabled", "position", "updated_at"])
else:
row.save(update_fields=["enabled", "updated_at"])
return redirect("command_routing")
return self._redirect_with_scope(request)
if action == "action_move":
row = get_object_or_404(
@@ -170,26 +263,74 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
)
direction = str(request.POST.get("direction") or "").strip().lower()
if direction not in {"up", "down"}:
return redirect("command_routing")
return self._redirect_with_scope(request)
with transaction.atomic():
ordered = self._normalize_action_positions(row.profile)
action_ids = [entry.id for entry in ordered]
try:
idx = action_ids.index(row.id)
except ValueError:
return redirect("command_routing")
return self._redirect_with_scope(request)
target_idx = idx - 1 if direction == "up" else idx + 1
if target_idx < 0 or target_idx >= len(ordered):
return redirect("command_routing")
return self._redirect_with_scope(request)
other = ordered[target_idx]
current_pos = ordered[idx].position
ordered[idx].position = other.position
other.position = current_pos
ordered[idx].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):

View File

@@ -28,6 +28,7 @@ from django.views import View
from core.clients import transport
from core.commands.base import CommandContext
from core.commands.engine import process_inbound_message
from core.commands.policies import ensure_variant_policies_for_profile
from core.messaging import ai as ai_runner
from core.messaging import media_bridge
from core.messaging.utils import messages_to_string
@@ -1610,6 +1611,29 @@ def _latest_whatsapp_bridge_ref(message: Message | None) -> dict:
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:
if reply_to is None:
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:
value = str(identifier or "").strip()
if not value:
@@ -1689,6 +1765,7 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
if (not created) and (not row.enabled):
row.enabled = True
row.save(update_fields=["enabled", "updated_at"])
ensure_variant_policies_for_profile(profile)
return profile
@@ -1779,46 +1856,102 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
channel_identifier__in=list(variants),
enabled=True,
).exists()
options.append(
{
"slug": slug,
"name": str(profile.name or slug).strip() or slug,
"trigger_token": str(profile.trigger_token or "").strip(),
"enabled_here": bool(enabled_here),
"profile_enabled": bool(profile.enabled),
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",
}
)
task_announce_enabled = False
if variants:
source = (
ChatTaskSource.objects.filter(
user=user,
service=service_key,
channel_identifier__in=list(variants),
enabled=True,
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(
{
"slug": slug,
"toggle_slug": slug,
"name": str(profile.name or slug).strip() or slug,
"trigger_token": str(profile.trigger_token or "").strip(),
"enabled_here": bool(enabled_here),
"profile_enabled": bool(profile.enabled),
"mode_label": "",
"enabled_label": "Enabled" if enabled_here else "Disabled",
}
)
.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
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(
*,
user,
@@ -2450,6 +2583,11 @@ def _panel_context(
base["service"],
base["identifier"],
)
bp_binding_summary = _bp_binding_summary_for_channel(
request.user,
base["service"],
base["identifier"],
)
recent_contacts = _recent_manual_contacts(
request.user,
current_service=base["service"],
@@ -2457,6 +2595,27 @@ def _panel_context(
current_person=base["person"],
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 {
"service": base["service"],
@@ -2485,6 +2644,9 @@ def _panel_context(
"compose_quick_insights_url": reverse("compose_quick_insights"),
"compose_history_sync_url": reverse("compose_history_sync"),
"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_ws_url": ws_url,
"tasks_hub_url": reverse("tasks_hub"),
@@ -2515,8 +2677,10 @@ def _panel_context(
"panel_id": f"compose-panel-{unique}",
"typing_state_json": json.dumps(typing_state),
"command_options": command_options,
"bp_binding_summary": bp_binding_summary,
"platform_options": platform_options,
"recent_contacts": recent_contacts,
"signal_ingest_warning": signal_ingest_warning,
"is_group": base.get("is_group", False),
"group_name": base.get("group_name", ""),
}
@@ -3366,6 +3530,8 @@ class ComposeToggleCommand(LoginRequiredMixin, View):
status=400,
)
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 {
"1",
"true",
@@ -3406,6 +3572,9 @@ class ComposeToggleCommand(LoginRequiredMixin, View):
if enabled
else f"{slug} disabled for this chat."
)
scoped_settings_url = (
f"{reverse('command_routing')}?{urlencode({'service': service, 'identifier': channel_identifier})}"
)
return JsonResponse(
{
"ok": True,
@@ -3416,7 +3585,7 @@ class ComposeToggleCommand(LoginRequiredMixin, View):
"settings_url": (
f"{reverse('tasks_settings')}?{urlencode({'service': service, 'identifier': channel_identifier})}"
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.",
"slug": "bp",
"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(
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":
runtime_state = transport.get_runtime_state("whatsapp")
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(
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)(
base["service"],
base["identifier"],

View File

@@ -3,6 +3,8 @@ from urllib.parse import urlencode
import orjson
import requests
from django.conf import settings
from django.contrib import messages
from django.db.models import Q
from django.shortcuts import render
from django.urls import reverse
from django.views import View
@@ -68,6 +70,11 @@ class SignalAccounts(SuperUserRequiredMixin, ObjectList):
"service": service,
"service_label": label,
"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,
"contacts_url_name": f"{service}_contacts",
"chats_url_name": f"{service}_chats",
@@ -89,6 +96,69 @@ class SignalAccounts(SuperUserRequiredMixin, ObjectList):
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):
list_template = "partials/signal-contacts-list.html"
@@ -141,7 +211,13 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
def get_queryset(self, *args, **kwargs):
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 = []
for chat in chats:
identifier_candidates = [

View File

@@ -23,6 +23,7 @@ from core.models import (
TaskProviderConfig,
PersonIdentifier,
PlatformChatLink,
Chat,
)
from core.tasks.providers.mock import get_provider
@@ -138,6 +139,25 @@ def _settings_redirect(request):
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:
key = str(service or "").strip().lower()
labels = {
@@ -158,9 +178,52 @@ def _resolve_channel_display(user, service: str, identifier: str) -> dict:
if bare_identifier and bare_identifier not in variants:
variants.append(bare_identifier)
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 ""
if group_identifier and group_identifier not in variants:
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
if bare_identifier:
@@ -200,9 +263,6 @@ def _resolve_channel_display(user, service: str, identifier: str) -> dict:
str(group_link.chat_jid or "").strip()
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 {
"service_key": service_key,
"service_label": _service_label(service_key),
@@ -275,15 +335,18 @@ class TaskGroupDetail(LoginRequiredMixin, View):
def get(self, request, service, identifier):
channel = _resolve_channel_display(request.user, service, identifier)
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(
user=request.user,
service=channel["service_key"],
service__in=service_keys,
channel_identifier__in=variants,
).select_related("project", "epic")
tasks = (
DerivedTask.objects.filter(
user=request.user,
source_service=channel["service_key"],
source_service__in=service_keys,
source_channel__in=variants,
)
.select_related("project", "epic")
@@ -330,6 +393,7 @@ class TaskSettings(LoginRequiredMixin, View):
def _context(self, request):
_apply_safe_defaults_for_user(request.user)
_ensure_default_completion_patterns(request.user)
prefill_service = str(request.GET.get("service") or "").strip().lower()
prefill_identifier = str(request.GET.get("identifier") or "").strip()
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"])
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":
phrase = str(request.POST.get("phrase") or "").strip()
if phrase:

View File

@@ -118,6 +118,9 @@ class WhatsAppAccounts(SuperUserRequiredMixin, ObjectList):
class WhatsAppAccountUnlink(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()
_ = transport.unlink_account("whatsapp", account)