Fix all integrations

This commit is contained in:
2026-03-08 22:08:55 +00:00
parent bca4d6898f
commit acedc01e83
58 changed files with 4120 additions and 960 deletions

View File

@@ -143,6 +143,7 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
"command_choices": (
("bp", "Business Plan (bp)"),
("codex", "Codex (codex)"),
("claude", "Claude (claude)"),
),
"scope_service": scope_service,
"scope_identifier": scope_identifier,
@@ -165,21 +166,27 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
.lower()
or "bp"
)
default_name = {
"bp": "Business Plan",
"codex": "Codex",
"claude": "Claude",
}.get(slug, "Business Plan")
default_trigger = {
"bp": ".bp",
"codex": ".codex",
"claude": ".claude",
}.get(slug, ".bp")
profile, _ = CommandProfile.objects.get_or_create(
user=request.user,
slug=slug,
defaults={
"name": str(
request.POST.get("name")
or ("Codex" if slug == "codex" else "Business Plan")
).strip()
or ("Codex" if slug == "codex" else "Business Plan"),
"name": str(request.POST.get("name") or default_name).strip()
or default_name,
"enabled": True,
"trigger_token": str(
request.POST.get("trigger_token")
or (".codex" if slug == "codex" else ".bp")
request.POST.get("trigger_token") or default_trigger
).strip()
or (".codex" if slug == "codex" else ".bp"),
or default_trigger,
"template_text": str(request.POST.get("template_text") or ""),
},
)
@@ -195,6 +202,10 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
profile.trigger_token = ".codex"
profile.reply_required = False
profile.exact_match_only = False
if slug == "claude":
profile.trigger_token = ".claude"
profile.reply_required = False
profile.exact_match_only = False
profile.save(
update_fields=[
"name",

View File

@@ -7,7 +7,7 @@ import time
from datetime import datetime
from datetime import timezone as dt_timezone
from difflib import SequenceMatcher
from urllib.parse import quote_plus, urlencode, urlparse
from urllib.parse import urlencode, urlparse
from asgiref.sync import async_to_sync
from django.conf import settings
@@ -136,6 +136,13 @@ def _identifier_variants(service: str, identifier: str) -> list[str]:
return variants
def _group_channel_identifier(service: str, group_link: PlatformChatLink, bare_id: str) -> str:
service_key = _default_service(service)
if service_key == "whatsapp":
return str(group_link.chat_jid or f"{bare_id}@g.us").strip()
return bare_id
def _safe_limit(raw) -> int:
try:
value = int(raw or 40)
@@ -424,7 +431,7 @@ def _extract_attachment_image_urls(blob) -> list[str]:
direct_urls.append(normalized)
urls.extend(direct_urls)
blob_key = str(blob.get("blob_key") or "").strip()
# Prefer source-hosted URLs (for example share.zm.is) and use blob fallback only
# Prefer source-hosted URLs and use blob fallback only
# when no usable direct URL exists.
if blob_key and image_hint and not direct_urls:
urls.append(f"/compose/media/blob/?key={quote_plus(blob_key)}")
@@ -1783,6 +1790,11 @@ def _context_base(user, service, identifier, person):
service=service,
identifier__in=identifier_variants or [identifier],
).first()
if person_identifier is None and identifier and person is None:
person_identifier = PersonIdentifier.objects.filter(
user=user,
identifier__in=identifier_variants or [identifier],
).first()
if person_identifier:
service = person_identifier.service
@@ -1811,7 +1823,7 @@ def _context_base(user, service, identifier, person):
return {
"person_identifier": None,
"service": service,
"identifier": f"{bare_id}@g.us",
"identifier": _group_channel_identifier(service, group_link, bare_id),
"person": None,
"is_group": True,
"group_name": group_link.chat_name or bare_id,
@@ -2426,6 +2438,63 @@ def _signal_identifier_shape(value: str) -> str:
return "other"
def _preferred_signal_identifier(identifiers: list[str], *, is_group: bool) -> str:
cleaned = []
for value in identifiers:
candidate = str(value or "").strip()
if candidate and candidate not in cleaned:
cleaned.append(candidate)
if not cleaned:
return ""
if is_group:
for candidate in cleaned:
if candidate.startswith("group."):
return candidate
return cleaned[0]
for candidate in cleaned:
if _signal_identifier_shape(candidate) == "phone":
return candidate
for candidate in cleaned:
if _signal_identifier_shape(candidate) == "uuid":
return candidate
return cleaned[0]
def _signal_runtime_alias_map() -> dict[str, set[str]]:
state = transport.get_runtime_state("signal") or {}
alias_map: dict[str, set[str]] = {}
for bucket_name in ("contacts", "groups"):
rows = state.get(bucket_name) or []
if not isinstance(rows, list):
continue
for item in rows:
if not isinstance(item, dict):
continue
identifiers = []
candidates = item.get("identifiers")
if isinstance(candidates, list):
identifiers.extend(candidates)
identifiers.extend(
[
item.get("identifier"),
item.get("number"),
item.get("uuid"),
item.get("id"),
item.get("internal_id"),
]
)
unique = []
for value in identifiers:
candidate = str(value or "").strip()
if candidate and candidate not in unique:
unique.append(candidate)
if len(unique) < 2:
continue
for identifier in unique:
alias_map[identifier] = {value for value in unique if value != identifier}
return alias_map
def _manual_contact_rows(user):
rows = []
seen = set()
@@ -2493,6 +2562,8 @@ def _manual_contact_rows(user):
)
for row in identifiers:
if _default_service(row.service) == "signal":
continue
add_row(
service=row.service,
identifier=row.identifier,
@@ -2500,6 +2571,27 @@ def _manual_contact_rows(user):
source="linked",
)
group_links = (
PlatformChatLink.objects.filter(user=user, is_group=True)
.order_by("service", "chat_name", "chat_identifier")
)
for link in group_links:
if _default_service(link.service) == "signal":
continue
group_identifier = _group_channel_identifier(
str(link.service or "").strip(),
link,
str(link.chat_identifier or "").strip(),
)
if not group_identifier:
continue
add_row(
service=link.service,
identifier=group_identifier,
source=f"{_default_service(link.service)}_group",
detected_name=str(link.chat_name or "").strip(),
)
signal_links = {
str(row.identifier): row
for row in (
@@ -2508,6 +2600,163 @@ def _manual_contact_rows(user):
.order_by("id")
)
}
signal_state = transport.get_runtime_state("signal") or {}
signal_accounts = [
str(value or "").strip()
for value in (signal_state.get("accounts") or [])
if str(value or "").strip()
]
signal_account_set = set(signal_accounts)
signal_entities = {}
signal_alias_index = {}
def _signal_entity_key(identifiers_list: list[str], *, is_group: bool) -> str:
preferred = _preferred_signal_identifier(identifiers_list, is_group=is_group)
if is_group:
return f"group:{preferred}"
for candidate in identifiers_list:
if _signal_identifier_shape(candidate) == "uuid":
return f"contact:uuid:{candidate.lower()}"
for candidate in identifiers_list:
if _signal_identifier_shape(candidate) == "phone":
return f"contact:phone:{candidate}"
return f"contact:other:{preferred.lower()}"
def _resolve_signal_entity_key(candidate: str) -> str:
cleaned = str(candidate or "").strip()
if not cleaned:
return ""
for variant in _identifier_variants("signal", cleaned):
entity_key = signal_alias_index.get(variant)
if entity_key:
return entity_key
return ""
def _register_signal_entity(
*,
identifiers_list,
is_group: bool,
detected_name="",
person=None,
source="signal_runtime",
):
unique_identifiers = []
for value in identifiers_list or []:
cleaned = str(value or "").strip()
if (
not cleaned
or cleaned in unique_identifiers
or cleaned in signal_account_set
):
continue
unique_identifiers.append(cleaned)
if not unique_identifiers:
return None
entity_key = ""
for identifier in unique_identifiers:
entity_key = _resolve_signal_entity_key(identifier)
if entity_key:
break
if not entity_key:
entity_key = _signal_entity_key(unique_identifiers, is_group=is_group)
entity = signal_entities.get(entity_key)
if entity is None:
entity = {
"is_group": bool(is_group),
"identifiers": [],
"detected_name": _clean_detected_name(detected_name or ""),
"person": person,
"sources": set(),
}
signal_entities[entity_key] = entity
for identifier in unique_identifiers:
if identifier not in entity["identifiers"]:
entity["identifiers"].append(identifier)
for variant in _identifier_variants("signal", identifier):
signal_alias_index[variant] = entity_key
cleaned_name = _clean_detected_name(detected_name or "")
if cleaned_name and not entity["detected_name"]:
entity["detected_name"] = cleaned_name
if person is not None and entity.get("person") is None:
entity["person"] = person
entity["sources"].add(str(source or "").strip() or "signal_runtime")
return entity
for row in signal_links.values():
_register_signal_entity(
identifiers_list=[row.identifier],
is_group=False,
detected_name=(str(row.person.name or "").strip() if row.person else ""),
person=row.person,
source="linked",
)
signal_group_links = (
PlatformChatLink.objects.filter(user=user, service="signal", is_group=True)
.order_by("chat_name", "chat_identifier")
)
for link in signal_group_links:
group_identifier = _group_channel_identifier(
"signal",
link,
str(link.chat_identifier or "").strip(),
)
if not group_identifier:
continue
_register_signal_entity(
identifiers_list=[group_identifier],
is_group=True,
detected_name=str(link.chat_name or "").strip(),
source="signal_group",
)
signal_contacts = signal_state.get("contacts") or []
if isinstance(signal_contacts, list):
for item in signal_contacts:
if not isinstance(item, dict):
continue
candidate_identifiers = item.get("identifiers")
if not isinstance(candidate_identifiers, list):
candidate_identifiers = [
item.get("identifier"),
item.get("number"),
item.get("uuid"),
]
linked = None
for candidate in candidate_identifiers:
cleaned = str(candidate or "").strip()
if not cleaned:
continue
linked = signal_links.get(cleaned)
if linked is not None:
break
_register_signal_entity(
identifiers_list=candidate_identifiers,
is_group=False,
detected_name=str(item.get("name") or "").strip(),
person=(linked.person if linked else None),
source="signal_runtime",
)
signal_groups = signal_state.get("groups") or []
if isinstance(signal_groups, list):
for item in signal_groups:
if not isinstance(item, dict):
continue
candidate_identifiers = item.get("identifiers")
if not isinstance(candidate_identifiers, list):
candidate_identifiers = [
item.get("identifier"),
item.get("id"),
item.get("internal_id"),
]
_register_signal_entity(
identifiers_list=candidate_identifiers,
is_group=True,
detected_name=str(item.get("name") or "").strip(),
source="signal_group_raw",
)
signal_chats = Chat.objects.all().order_by("-id")[:500]
for chat in signal_chats:
uuid_candidate = str(chat.source_uuid or "").strip()
@@ -2517,20 +2766,45 @@ def _manual_contact_rows(user):
fallback_linked = signal_links.get(uuid_candidate)
if fallback_linked is None and number_candidate:
fallback_linked = signal_links.get(number_candidate)
for candidate in (uuid_candidate, number_candidate):
if not candidate:
continue
linked = signal_links.get(candidate) or fallback_linked
add_row(
service="signal",
identifier=candidate,
person=(linked.person if linked else None),
source="signal_chat",
account=str(chat.account or ""),
detected_name=_clean_detected_name(
chat.source_name or chat.account or ""
),
)
linked = fallback_linked
if linked is None:
for candidate in (uuid_candidate, number_candidate):
linked = signal_links.get(candidate)
if linked is not None:
break
_register_signal_entity(
identifiers_list=[uuid_candidate, number_candidate],
is_group=False,
detected_name=_clean_detected_name(chat.source_name or chat.account or ""),
person=(linked.person if linked else None),
source="signal_chat",
)
for entity in signal_entities.values():
entity_identifiers = list(entity.get("identifiers") or [])
identifier_value = _preferred_signal_identifier(
entity_identifiers,
is_group=bool(entity.get("is_group")),
)
if not identifier_value:
continue
add_row(
service="signal",
identifier=identifier_value,
person=entity.get("person"),
source=",".join(sorted(entity.get("sources") or {"signal_runtime"})),
account=entity.get("detected_name") or "",
detected_name=entity.get("detected_name") or "",
)
if rows:
rows[-1]["identifier_aliases"] = [
candidate
for candidate in entity_identifiers
if str(candidate or "").strip() and candidate != identifier_value
]
rows[-1]["identifier_search"] = " ".join(
[rows[-1]["identifier"]] + rows[-1]["identifier_aliases"]
).strip()
whatsapp_links = {
str(row.identifier): row
@@ -3225,8 +3499,11 @@ class ComposeContactMatch(LoginRequiredMixin, View):
value = str(identifier or "").strip()
if not value:
return set()
source_shape = _signal_identifier_shape(value)
companions = set()
runtime_aliases = _signal_runtime_alias_map()
for variant in _identifier_variants("signal", value):
companions.update(runtime_aliases.get(variant) or set())
source_shape = _signal_identifier_shape(value)
signal_rows = Chat.objects.filter(source_uuid=value) | Chat.objects.filter(
source_number=value
)

View File

@@ -5,6 +5,7 @@ import requests
from django.conf import settings
from django.contrib import messages
from django.db.models import Q
from django.http import HttpResponse
from django.shortcuts import render
from django.urls import reverse
from django.views import View
@@ -141,7 +142,12 @@ class SignalAccountUnlink(SuperUserRequiredMixin, View):
else:
messages.error(
request,
"Signal relink failed to clear current device state. Try relink again.",
(
"Signal relink could not verify that the current device state was "
"cleared. The account may still be linked in signal-cli-rest-api. "
"Try relink again, and if it still fails, restart the Signal service "
"before requesting a new QR code."
),
)
else:
messages.warning(request, "No Signal account selected to relink.")
@@ -324,15 +330,16 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
group_id = str(link.chat_identifier or "").strip()
if not group_id:
continue
query = urlencode({"service": "signal", "identifier": group_id})
rows.append(
{
"chat": None,
"compose_page_url": "",
"compose_widget_url": "",
"compose_page_url": f"{reverse('compose_page')}?{query}",
"compose_widget_url": f"{reverse('compose_widget')}?{query}",
"ai_url": reverse("ai_workspace"),
"person_name": "",
"manual_icon_class": "fa-solid fa-users",
"can_compose": False,
"can_compose": True,
"match_url": "",
"is_group": True,
"name": link.chat_name or group_id,
@@ -412,7 +419,21 @@ class SignalAccountAdd(SuperUserRequiredMixin, CustomObjectRead):
def get_object(self, **kwargs):
form_args = self.request.POST.dict()
device_name = form_args["device"]
image_bytes = transport.get_link_qr(self.service, device_name)
try:
image_bytes = transport.get_link_qr(self.service, device_name)
except Exception as exc:
return render(
self.request,
"mixins/wm/modal.html",
{
"window_content": "mixins/partials/notify.html",
"message": (
"Signal QR link is unavailable right now. "
f"signal-cli-rest-api did not return a QR in time: {exc}"
),
"class": "danger",
},
)
base64_image = transport.image_bytes_to_base64(image_bytes)
return base64_image

View File

@@ -40,6 +40,14 @@ from core.models import (
WorkspaceConversation,
WorkspaceMetricSnapshot,
)
from core.security.capabilities import (
CAPABILITY_SCOPES,
)
from core.security.capabilities import GLOBAL_SCOPE_KEY as COMMAND_GLOBAL_SCOPE_KEY
from core.security.capabilities import GROUP_LABELS as CAPABILITY_GROUP_LABELS
from core.security.capabilities import (
scope_record,
)
from core.transports.capabilities import capability_snapshot
from core.views.manage.permissions import SuperUserRequiredMixin
@@ -528,7 +536,7 @@ class SecurityPage(LoginRequiredMixin, View):
template_name = "pages/security.html"
page_mode = "encryption"
GLOBAL_SCOPE_KEY = "global.override"
GLOBAL_SCOPE_KEY = COMMAND_GLOBAL_SCOPE_KEY
# Allowed Services list used by both Global Scope Override and local scopes.
# Keep this in sync with the UI text on the Security page.
POLICY_SERVICES = ["xmpp", "whatsapp", "signal", "instagram", "web"]
@@ -541,47 +549,7 @@ class SecurityPage(LoginRequiredMixin, View):
"require_omemo",
"require_trusted_fingerprint",
)
POLICY_SCOPES = [
(
"gateway.tasks",
"Gateway .tasks commands",
"Handles .tasks list/show/complete/undo over gateway channels.",
),
(
"gateway.approval",
"Gateway approval commands",
"Handles .approval/.codex/.claude approve/deny over gateway channels.",
),
(
"gateway.totp",
"Gateway TOTP enrollment",
"Controls TOTP enrollment/status commands over gateway channels.",
),
(
"tasks.submit",
"Task submissions from chat",
"Controls automatic task creation from inbound messages.",
),
(
"tasks.commands",
"Task command verbs (.task/.undo/.epic)",
"Controls explicit task command verbs.",
),
(
"command.bp",
"Business plan command",
"Controls Business Plan command execution.",
),
("command.codex", "Codex command", "Controls Codex command execution."),
("command.claude", "Claude command", "Controls Claude command execution."),
]
POLICY_GROUP_LABELS = {
"gateway": "Gateway",
"tasks": "Tasks",
"command": "Commands",
"agentic": "Agentic",
"other": "Other",
}
POLICY_GROUP_LABELS = CAPABILITY_GROUP_LABELS
def _show_encryption(self) -> bool:
return str(getattr(self, "page_mode", "encryption")).strip().lower() in {
@@ -774,8 +742,10 @@ class SecurityPage(LoginRequiredMixin, View):
)
}
payload = []
for scope_key, label, description in self.POLICY_SCOPES:
key = str(scope_key or "").strip().lower()
for scope in CAPABILITY_SCOPES:
if not bool(scope.configurable):
continue
key = str(scope.key or "").strip().lower()
item = rows.get(key)
raw_allowed_services = [
str(value or "").strip().lower()
@@ -797,8 +767,8 @@ class SecurityPage(LoginRequiredMixin, View):
payload.append(
{
"scope_key": key,
"label": label,
"description": description,
"label": scope.label,
"description": scope.description,
"enabled": self._apply_global_override(
bool(getattr(item, "enabled", True)),
global_overrides["scope_enabled"],
@@ -827,38 +797,20 @@ class SecurityPage(LoginRequiredMixin, View):
return payload
def _scope_group_key(self, scope_key: str) -> str:
key = str(scope_key or "").strip().lower()
if key in {"tasks.commands", "gateway.tasks"}:
return "tasks"
if key in {"command.codex", "command.claude"}:
return "agentic"
if key.startswith("gateway."):
return "command"
if key.startswith("tasks."):
if key == "tasks.submit":
return "tasks"
return "command"
if key.startswith("command."):
return "command"
if ".commands" in key:
return "command"
if ".approval" in key:
return "command"
if ".totp" in key:
return "command"
if ".task" in key:
return "tasks"
return "other"
row = scope_record(scope_key)
return row.group if row is not None else "other"
def _grouped_scope_rows(self, request):
rows = self._scope_rows(request)
grouped: dict[str, list[dict]] = {key: [] for key in self.POLICY_GROUP_LABELS}
grouped: dict[str, list[dict]] = {
key: [] for key in self.POLICY_GROUP_LABELS.keys()
}
for row in rows:
group_key = self._scope_group_key(row.get("scope_key"))
grouped.setdefault(group_key, [])
grouped[group_key].append(row)
payload = []
for group_key in ("tasks", "command", "agentic", "other"):
for group_key in ("gateway", "tasks", "command", "agentic", "other"):
items = grouped.get(group_key) or []
if not items:
continue
@@ -875,6 +827,10 @@ class SecurityPage(LoginRequiredMixin, View):
row = self._security_settings(request)
if str(request.POST.get("encryption_settings_submit") or "").strip() == "1":
row.require_omemo = _to_bool(request.POST.get("require_omemo"), False)
row.encrypt_component_messages_with_omemo = _to_bool(
request.POST.get("encrypt_component_messages_with_omemo"),
True,
)
row.encrypt_contact_messages_with_omemo = _to_bool(
request.POST.get("encrypt_contact_messages_with_omemo"),
False,
@@ -882,6 +838,7 @@ class SecurityPage(LoginRequiredMixin, View):
row.save(
update_fields=[
"require_omemo",
"encrypt_component_messages_with_omemo",
"encrypt_contact_messages_with_omemo",
"updated_at",
]

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import datetime
import hashlib
import json
from urllib.parse import urlencode
@@ -40,6 +41,7 @@ from core.tasks.chat_defaults import (
)
from core.tasks.codex_approval import queue_codex_event_with_pre_approval
from core.tasks.codex_support import resolve_external_chat_id
from core.tasks.engine import create_task_record_and_sync
from core.tasks.providers import get_provider
@@ -828,6 +830,9 @@ class TasksHub(LoginRequiredMixin, View):
return {
"projects": projects,
"project_choices": all_projects,
"epic_choices": TaskEpic.objects.filter(
project__user=request.user
).select_related("project").order_by("project__name", "name"),
"tasks": tasks,
"scope": scope,
"person_identifier_rows": person_identifier_rows,
@@ -875,6 +880,60 @@ class TasksHub(LoginRequiredMixin, View):
return redirect(f"{reverse('tasks_hub')}?{urlencode(query)}")
return redirect("tasks_hub")
if action == "task_create":
project = get_object_or_404(
TaskProject,
user=request.user,
id=request.POST.get("project_id"),
)
epic = None
epic_id = str(request.POST.get("epic_id") or "").strip()
if epic_id:
epic = get_object_or_404(TaskEpic, id=epic_id, project=project)
title = str(request.POST.get("title") or "").strip()
if not title:
messages.error(request, "Task title is required.")
return redirect("tasks_hub")
scope = self._scope(request)
source_service = str(scope.get("service") or "").strip().lower() or "web"
source_channel = str(scope.get("identifier") or "").strip()
due_raw = str(request.POST.get("due_date") or "").strip()
due_date = None
if due_raw:
try:
due_date = datetime.date.fromisoformat(due_raw)
except Exception:
messages.error(request, "Due date must be YYYY-MM-DD.")
return redirect("tasks_hub")
task, _event = async_to_sync(create_task_record_and_sync)(
user=request.user,
project=project,
epic=epic,
title=title,
source_service=source_service,
source_channel=source_channel,
actor_identifier=str(request.user.username or request.user.id),
due_date=due_date,
assignee_identifier=str(
request.POST.get("assignee_identifier") or ""
).strip(),
immutable_payload={
"source": "tasks_hub_manual_create",
"person_id": scope["person_id"],
"service": source_service,
"identifier": source_channel,
},
event_payload={
"source": "tasks_hub_manual_create",
"via": "web_ui",
},
)
messages.success(
request,
f"Created task #{task.reference_code} in '{project.name}'.",
)
return redirect("tasks_task", task_id=str(task.id))
if action == "project_map_identifier":
project = get_object_or_404(
TaskProject,