Increase security and reformat

This commit is contained in:
2026-03-07 20:52:13 +00:00
parent 10588a18b9
commit bca4d6898f
144 changed files with 6735 additions and 3960 deletions

View File

@@ -1,4 +1,5 @@
"""Test-only settings overrides — used via DJANGO_SETTINGS_MODULE=app.test_settings.""" """Test-only settings overrides — used via DJANGO_SETTINGS_MODULE=app.test_settings."""
from app.settings import * # noqa: F401, F403 from app.settings import * # noqa: F401, F403
CACHES = { CACHES = {

View File

@@ -13,12 +13,13 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.views import LogoutView from django.contrib.auth.views import LogoutView
from django.views.generic import RedirectView
from django.urls import include, path from django.urls import include, path
from django.views.generic import RedirectView
from two_factor.urls import urlpatterns as tf_urls from two_factor.urls import urlpatterns as tf_urls
from two_factor.views.profile import ProfileView from two_factor.views.profile import ProfileView
@@ -41,8 +42,8 @@ from core.views import (
queues, queues,
sessions, sessions,
signal, signal,
tasks,
system, system,
tasks,
whatsapp, whatsapp,
workspace, workspace,
) )
@@ -188,6 +189,11 @@ urlpatterns = [
automation.TranslationSettings.as_view(), automation.TranslationSettings.as_view(),
name="translation_settings", name="translation_settings",
), ),
path(
"settings/business-plans/",
automation.BusinessPlanInbox.as_view(),
name="business_plan_inbox",
),
path( path(
"settings/business-plan/<str:doc_id>/", "settings/business-plan/<str:doc_id>/",
automation.BusinessPlanEditor.as_view(), automation.BusinessPlanEditor.as_view(),

View File

@@ -38,14 +38,31 @@ def _is_question(text: str) -> bool:
if not body: if not body:
return False return False
low = body.lower() low = body.lower()
return body.endswith("?") or low.startswith(("what", "why", "how", "when", "where", "who", "can ", "do ", "did ", "is ", "are ")) return body.endswith("?") or low.startswith(
(
"what",
"why",
"how",
"when",
"where",
"who",
"can ",
"do ",
"did ",
"is ",
"are ",
)
)
def _is_group_channel(message: Message) -> bool: def _is_group_channel(message: Message) -> bool:
channel = str(getattr(message, "source_chat_id", "") or "").strip().lower() channel = str(getattr(message, "source_chat_id", "") or "").strip().lower()
if channel.endswith("@g.us"): if channel.endswith("@g.us"):
return True return True
return str(getattr(message, "source_service", "") or "").strip().lower() == "xmpp" and "conference." in channel return (
str(getattr(message, "source_service", "") or "").strip().lower() == "xmpp"
and "conference." in channel
)
async def learn_from_message(message: Message) -> None: async def learn_from_message(message: Message) -> None:

View File

@@ -12,8 +12,7 @@ class ClientBase(ABC):
self.log.info(f"{self.service.capitalize()} client initialising...") self.log.info(f"{self.service.capitalize()} client initialising...")
@abstractmethod @abstractmethod
def start(self): def start(self): ...
...
# @abstractmethod # @abstractmethod
# async def send_message(self, recipient, message): # async def send_message(self, recipient, message):

View File

@@ -12,7 +12,15 @@ from django.urls import reverse
from signalbot import Command, Context, SignalBot from signalbot import Command, Context, SignalBot
from core.clients import ClientBase, signalapi, transport 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 ( from core.models import (
Chat, Chat,
Manipulation, Manipulation,
@@ -402,7 +410,9 @@ class NewSignalBot(SignalBot):
seen_user_ids.add(pi.user_id) seen_user_ids.add(pi.user_id)
users.append(pi.user) users.append(pi.user)
if not users: if not users:
self.log.debug("[Signal] _upsert_groups: no PersonIdentifiers found — skipping") self.log.debug(
"[Signal] _upsert_groups: no PersonIdentifiers found — skipping"
)
return return
for user in users: for user in users:
@@ -423,7 +433,9 @@ class NewSignalBot(SignalBot):
}, },
) )
self.log.info("[Signal] upserted %d groups for %d users", len(groups), len(users)) self.log.info(
"[Signal] upserted %d groups for %d users", len(groups), len(users)
)
async def _detect_groups(self): async def _detect_groups(self):
await super()._detect_groups() await super()._detect_groups()
@@ -505,7 +517,9 @@ class HandleMessage(Command):
source_uuid_norm and dest_norm and source_uuid_norm == dest_norm 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. # Inbound deliveries usually do not have destination fields populated.
@@ -596,9 +610,9 @@ class HandleMessage(Command):
candidate_digits = {value for value in candidate_digits if value} candidate_digits = {value for value in candidate_digits if value}
if candidate_digits: if candidate_digits:
signal_rows = await sync_to_async(list)( signal_rows = await sync_to_async(list)(
PersonIdentifier.objects.filter(service=self.service).select_related( PersonIdentifier.objects.filter(
"user" service=self.service
) ).select_related("user")
) )
matched = [] matched = []
for row in signal_rows: for row in signal_rows:
@@ -718,13 +732,13 @@ 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=( actor=(effective_source_uuid or effective_source_number or ""),
effective_source_uuid or effective_source_number or ""
),
target_author=str( target_author=str(
(reaction_payload.get("raw") or {}).get("targetAuthorUuid") (reaction_payload.get("raw") or {}).get("targetAuthorUuid")
or (reaction_payload.get("raw") or {}).get("targetAuthor") or (reaction_payload.get("raw") or {}).get("targetAuthor")
or (reaction_payload.get("raw") or {}).get("targetAuthorNumber") or (reaction_payload.get("raw") or {}).get(
"targetAuthorNumber"
)
or "" or ""
), ),
remove=bool(reaction_payload.get("remove")), remove=bool(reaction_payload.get("remove")),
@@ -741,9 +755,7 @@ 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=( actor=(effective_source_uuid or effective_source_number or ""),
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:
@@ -840,9 +852,7 @@ class HandleMessage(Command):
source_ref={ source_ref={
"upstream_message_id": "", "upstream_message_id": "",
"upstream_author": str( "upstream_author": str(
effective_source_uuid effective_source_uuid or effective_source_number or ""
or effective_source_number
or ""
), ),
"upstream_ts": int(ts or 0), "upstream_ts": int(ts or 0),
}, },
@@ -1134,7 +1144,9 @@ class SignalClient(ClientBase):
if int(message_row.delivered_ts or 0) <= 0: if int(message_row.delivered_ts or 0) <= 0:
message_row.delivered_ts = int(result) message_row.delivered_ts = int(result)
update_fields.append("delivered_ts") update_fields.append("delivered_ts")
if str(message_row.source_message_id or "").strip() != str(result): if str(message_row.source_message_id or "").strip() != str(
result
):
message_row.source_message_id = str(result) message_row.source_message_id = str(result)
update_fields.append("source_message_id") update_fields.append("source_message_id")
if update_fields: if update_fields:
@@ -1146,9 +1158,11 @@ class SignalClient(ClientBase):
command_id, command_id,
{ {
"ok": True, "ok": True,
"timestamp": int(result) "timestamp": (
if isinstance(result, int) int(result)
else int(time.time() * 1000), if isinstance(result, int)
else int(time.time() * 1000)
),
}, },
) )
except Exception as exc: except Exception as exc:
@@ -1248,7 +1262,9 @@ class SignalClient(ClientBase):
if _digits_only(getattr(row, "identifier", "")) in candidate_digits if _digits_only(getattr(row, "identifier", "")) in candidate_digits
] ]
async def _auto_link_single_user_signal_identifier(self, source_uuid: str, source_number: str): async def _auto_link_single_user_signal_identifier(
self, source_uuid: str, source_number: str
):
owner_rows = await sync_to_async(list)( owner_rows = await sync_to_async(list)(
PersonIdentifier.objects.filter(service=self.service) PersonIdentifier.objects.filter(service=self.service)
.select_related("user") .select_related("user")
@@ -1292,7 +1308,9 @@ class SignalClient(ClientBase):
payload = json.loads(raw_message or "{}") payload = json.loads(raw_message or "{}")
except Exception: except Exception:
return return
exception_payload = payload.get("exception") if isinstance(payload, dict) else None exception_payload = (
payload.get("exception") if isinstance(payload, dict) else None
)
if isinstance(exception_payload, dict): if isinstance(exception_payload, dict):
err_type = str(exception_payload.get("type") or "").strip() err_type = str(exception_payload.get("type") or "").strip()
err_msg = str(exception_payload.get("message") or "").strip() err_msg = str(exception_payload.get("message") or "").strip()
@@ -1322,7 +1340,9 @@ class SignalClient(ClientBase):
(envelope.get("timestamp") if isinstance(envelope, dict) else 0) (envelope.get("timestamp") if isinstance(envelope, dict) else 0)
or int(time.time() * 1000) or int(time.time() * 1000)
), ),
last_inbound_exception_account=str(payload.get("account") or "").strip(), last_inbound_exception_account=str(
payload.get("account") or ""
).strip(),
last_inbound_exception_source_uuid=envelope_source_uuid, last_inbound_exception_source_uuid=envelope_source_uuid,
last_inbound_exception_source_number=envelope_source_number, last_inbound_exception_source_number=envelope_source_number,
last_inbound_exception_envelope_ts=envelope_ts, last_inbound_exception_envelope_ts=envelope_ts,
@@ -1346,7 +1366,11 @@ class SignalClient(ClientBase):
raw_text = sync_sent_message.get("message") raw_text = sync_sent_message.get("message")
if isinstance(raw_text, dict): if isinstance(raw_text, dict):
text = _extract_signal_text( text = _extract_signal_text(
{"envelope": {"syncMessage": {"sentMessage": {"message": raw_text}}}}, {
"envelope": {
"syncMessage": {"sentMessage": {"message": raw_text}}
}
},
str( str(
raw_text.get("message") raw_text.get("message")
or raw_text.get("text") or raw_text.get("text")
@@ -1396,9 +1420,15 @@ class SignalClient(ClientBase):
source_service="signal", source_service="signal",
actor=(source_uuid or source_number or ""), actor=(source_uuid or source_number or ""),
target_author=str( target_author=str(
(reaction_payload.get("raw") or {}).get("targetAuthorUuid") (reaction_payload.get("raw") or {}).get(
or (reaction_payload.get("raw") or {}).get("targetAuthor") "targetAuthorUuid"
or (reaction_payload.get("raw") or {}).get("targetAuthorNumber") )
or (reaction_payload.get("raw") or {}).get(
"targetAuthor"
)
or (reaction_payload.get("raw") or {}).get(
"targetAuthorNumber"
)
or "" or ""
), ),
remove=bool(reaction_payload.get("remove")), remove=bool(reaction_payload.get("remove")),
@@ -1505,7 +1535,9 @@ class SignalClient(ClientBase):
source_chat_id = destination_number or destination_uuid or sender_key source_chat_id = destination_number or destination_uuid or sender_key
reply_ref = reply_sync.extract_reply_ref(self.service, payload) reply_ref = reply_sync.extract_reply_ref(self.service, payload)
for identifier in identifiers: for identifier in identifiers:
session = await history.get_chat_session(identifier.user, identifier) session = await history.get_chat_session(
identifier.user, identifier
)
reply_target = await reply_sync.resolve_reply_target( reply_target = await reply_sync.resolve_reply_target(
identifier.user, identifier.user,
session, session,
@@ -1552,13 +1584,19 @@ class SignalClient(ClientBase):
if not isinstance(data_message, dict): if not isinstance(data_message, dict):
return return
source_uuid = str(envelope.get("sourceUuid") or envelope.get("source") or "").strip() source_uuid = str(
envelope.get("sourceUuid") or envelope.get("source") or ""
).strip()
source_number = str(envelope.get("sourceNumber") or "").strip() source_number = str(envelope.get("sourceNumber") or "").strip()
bot_uuid = str(getattr(self.client, "bot_uuid", "") or "").strip() bot_uuid = str(getattr(self.client, "bot_uuid", "") or "").strip()
bot_phone = str(getattr(self.client, "phone_number", "") or "").strip() bot_phone = str(getattr(self.client, "phone_number", "") or "").strip()
if source_uuid and bot_uuid and source_uuid == bot_uuid: if source_uuid and bot_uuid and source_uuid == bot_uuid:
return return
if source_number and bot_phone and _digits_only(source_number) == _digits_only(bot_phone): if (
source_number
and bot_phone
and _digits_only(source_number) == _digits_only(bot_phone)
):
return return
identifiers = await self._resolve_signal_identifiers(source_uuid, source_number) identifiers = await self._resolve_signal_identifiers(source_uuid, source_number)
@@ -1610,14 +1648,18 @@ class SignalClient(ClientBase):
target_author=str( target_author=str(
(reaction_payload.get("raw") or {}).get("targetAuthorUuid") (reaction_payload.get("raw") or {}).get("targetAuthorUuid")
or (reaction_payload.get("raw") or {}).get("targetAuthor") or (reaction_payload.get("raw") or {}).get("targetAuthor")
or (reaction_payload.get("raw") or {}).get("targetAuthorNumber") or (reaction_payload.get("raw") or {}).get(
"targetAuthorNumber"
)
or "" 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 {},
) )
except Exception as exc: except Exception as exc:
self.log.warning("signal raw reaction history apply failed: %s", exc) self.log.warning(
"signal raw reaction history apply failed: %s", exc
)
try: try:
await self.ur.xmpp.client.apply_external_reaction( await self.ur.xmpp.client.apply_external_reaction(
identifier.user, identifier.user,
@@ -1631,7 +1673,9 @@ class SignalClient(ClientBase):
payload=reaction_payload.get("raw") or {}, payload=reaction_payload.get("raw") or {},
) )
except Exception as exc: except Exception as exc:
self.log.warning("signal raw reaction relay to XMPP failed: %s", exc) self.log.warning(
"signal raw reaction relay to XMPP failed: %s", exc
)
transport.update_runtime_state( transport.update_runtime_state(
self.service, self.service,
last_inbound_ok_ts=int(time.time() * 1000), last_inbound_ok_ts=int(time.time() * 1000),
@@ -1683,7 +1727,9 @@ class SignalClient(ClientBase):
) )
return return
text = _extract_signal_text(payload, str(data_message.get("message") or "").strip()) text = _extract_signal_text(
payload, str(data_message.get("message") or "").strip()
)
if not text: if not text:
return return
@@ -1702,7 +1748,11 @@ class SignalClient(ClientBase):
or envelope.get("timestamp") or envelope.get("timestamp")
or ts or ts
).strip() ).strip()
sender_key = source_uuid or source_number or (identifiers[0].identifier if identifiers else "") 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 source_chat_id = source_number or source_uuid or sender_key
reply_ref = reply_sync.extract_reply_ref(self.service, payload) reply_ref = reply_sync.extract_reply_ref(self.service, payload)

View File

@@ -927,7 +927,11 @@ async def send_reaction(
service_key = _service_key(service) service_key = _service_key(service)
if _capability_checks_enabled() and not supports(service_key, "reactions"): if _capability_checks_enabled() and not supports(service_key, "reactions"):
reason = unsupported_reason(service_key, "reactions") reason = unsupported_reason(service_key, "reactions")
log.warning("capability-check failed service=%s feature=reactions: %s", service_key, reason) log.warning(
"capability-check failed service=%s feature=reactions: %s",
service_key,
reason,
)
return False return False
if not str(emoji or "").strip() and not remove: if not str(emoji or "").strip() and not remove:
return False return False

View File

@@ -173,9 +173,7 @@ class WhatsAppClient(ClientBase):
if db_dir: if db_dir:
os.makedirs(db_dir, exist_ok=True) os.makedirs(db_dir, exist_ok=True)
if db_dir and not os.access(db_dir, os.W_OK): if db_dir and not os.access(db_dir, os.W_OK):
raise PermissionError( raise PermissionError(f"session db directory is not writable: {db_dir}")
f"session db directory is not writable: {db_dir}"
)
except Exception as exc: except Exception as exc:
self._publish_state( self._publish_state(
connected=False, connected=False,
@@ -772,9 +770,11 @@ class WhatsAppClient(ClientBase):
command_id, command_id,
{ {
"ok": True, "ok": True,
"timestamp": int(result) "timestamp": (
if isinstance(result, int) int(result)
else int(time.time() * 1000), if isinstance(result, int)
else int(time.time() * 1000)
),
}, },
) )
self.log.debug( self.log.debug(
@@ -1910,9 +1910,7 @@ class WhatsAppClient(ClientBase):
jid_value = self._jid_to_identifier( jid_value = self._jid_to_identifier(
self._pluck(group, "JID") or self._pluck(group, "jid") self._pluck(group, "JID") or self._pluck(group, "jid")
) )
identifier = ( identifier = jid_value.split("@", 1)[0].strip() if jid_value else ""
jid_value.split("@", 1)[0].strip() if jid_value else ""
)
if not identifier: if not identifier:
continue continue
name = ( name = (
@@ -2362,12 +2360,22 @@ class WhatsAppClient(ClientBase):
node = ( node = (
self._pluck(message_obj, "reactionMessage") self._pluck(message_obj, "reactionMessage")
or self._pluck(message_obj, "reaction_message") or self._pluck(message_obj, "reaction_message")
or self._pluck(message_obj, "ephemeralMessage", "message", "reactionMessage") or self._pluck(
or self._pluck(message_obj, "ephemeral_message", "message", "reaction_message") message_obj, "ephemeralMessage", "message", "reactionMessage"
)
or self._pluck(
message_obj, "ephemeral_message", "message", "reaction_message"
)
or self._pluck(message_obj, "viewOnceMessage", "message", "reactionMessage") or self._pluck(message_obj, "viewOnceMessage", "message", "reactionMessage")
or self._pluck(message_obj, "view_once_message", "message", "reaction_message") or self._pluck(
or self._pluck(message_obj, "viewOnceMessageV2", "message", "reactionMessage") message_obj, "view_once_message", "message", "reaction_message"
or self._pluck(message_obj, "view_once_message_v2", "message", "reaction_message") )
or self._pluck(
message_obj, "viewOnceMessageV2", "message", "reactionMessage"
)
or self._pluck(
message_obj, "view_once_message_v2", "message", "reaction_message"
)
or self._pluck( or self._pluck(
message_obj, message_obj,
"viewOnceMessageV2Extension", "viewOnceMessageV2Extension",
@@ -2410,7 +2418,9 @@ class WhatsAppClient(ClientBase):
explicit_remove = self._pluck(node, "remove") or self._pluck(node, "isRemove") explicit_remove = self._pluck(node, "remove") or self._pluck(node, "isRemove")
if explicit_remove is None: if explicit_remove is None:
explicit_remove = self._pluck(node, "is_remove") explicit_remove = self._pluck(node, "is_remove")
remove = bool(explicit_remove) if explicit_remove is not None else bool(not emoji) remove = (
bool(explicit_remove) if explicit_remove is not None else bool(not emoji)
)
if not target_msg_id: if not target_msg_id:
return None return None
return { return {
@@ -2418,7 +2428,11 @@ class WhatsAppClient(ClientBase):
"target_message_id": target_msg_id, "target_message_id": target_msg_id,
"remove": remove, "remove": remove,
"target_ts": int(target_ts or 0), "target_ts": int(target_ts or 0),
"raw": self._proto_to_dict(node) or dict(node or {}) if isinstance(node, dict) else {}, "raw": (
self._proto_to_dict(node) or dict(node or {})
if isinstance(node, dict)
else {}
),
} }
async def _download_event_media(self, event): async def _download_event_media(self, event):
@@ -2760,7 +2774,9 @@ class WhatsAppClient(ClientBase):
or self._pluck(msg_obj, "MessageContextInfo") or self._pluck(msg_obj, "MessageContextInfo")
or {}, or {},
"message": { "message": {
"extendedTextMessage": self._pluck(msg_obj, "extendedTextMessage") "extendedTextMessage": self._pluck(
msg_obj, "extendedTextMessage"
)
or self._pluck(msg_obj, "ExtendedTextMessage") or self._pluck(msg_obj, "ExtendedTextMessage")
or {}, or {},
"imageMessage": self._pluck(msg_obj, "imageMessage") or {}, "imageMessage": self._pluck(msg_obj, "imageMessage") or {},
@@ -2768,9 +2784,9 @@ class WhatsAppClient(ClientBase):
"videoMessage": self._pluck(msg_obj, "videoMessage") or {}, "videoMessage": self._pluck(msg_obj, "videoMessage") or {},
"VideoMessage": self._pluck(msg_obj, "VideoMessage") or {}, "VideoMessage": self._pluck(msg_obj, "VideoMessage") or {},
"documentMessage": self._pluck(msg_obj, "documentMessage") "documentMessage": self._pluck(msg_obj, "documentMessage")
or {}, or {},
"DocumentMessage": self._pluck(msg_obj, "DocumentMessage") "DocumentMessage": self._pluck(msg_obj, "DocumentMessage")
or {}, or {},
"ephemeralMessage": self._pluck(msg_obj, "ephemeralMessage") "ephemeralMessage": self._pluck(msg_obj, "ephemeralMessage")
or {}, or {},
"EphemeralMessage": self._pluck(msg_obj, "EphemeralMessage") "EphemeralMessage": self._pluck(msg_obj, "EphemeralMessage")
@@ -2814,7 +2830,9 @@ class WhatsAppClient(ClientBase):
or {}, or {},
"viewOnceMessage": self._pluck(msg_obj, "viewOnceMessage") "viewOnceMessage": self._pluck(msg_obj, "viewOnceMessage")
or {}, or {},
"viewOnceMessageV2": self._pluck(msg_obj, "viewOnceMessageV2") "viewOnceMessageV2": self._pluck(
msg_obj, "viewOnceMessageV2"
)
or {}, or {},
"viewOnceMessageV2Extension": self._pluck( "viewOnceMessageV2Extension": self._pluck(
msg_obj, "viewOnceMessageV2Extension" msg_obj, "viewOnceMessageV2Extension"
@@ -2840,12 +2858,12 @@ class WhatsAppClient(ClientBase):
reply_sync.extract_origin_tag(payload), reply_sync.extract_origin_tag(payload),
) )
if self._chat_matches_reply_debug(chat): if self._chat_matches_reply_debug(chat):
info_obj = self._proto_to_dict(self._pluck(event_obj, "Info")) or self._pluck( info_obj = self._proto_to_dict(
event_obj, "Info" self._pluck(event_obj, "Info")
) ) or self._pluck(event_obj, "Info")
raw_obj = self._proto_to_dict(self._pluck(event_obj, "Raw")) or self._pluck( raw_obj = self._proto_to_dict(
event_obj, "Raw" self._pluck(event_obj, "Raw")
) ) or self._pluck(event_obj, "Raw")
message_meta["wa_reply_debug"] = { message_meta["wa_reply_debug"] = {
"reply_ref": reply_ref, "reply_ref": reply_ref,
"reply_target_id": str(getattr(reply_target, "id", "") or ""), "reply_target_id": str(getattr(reply_target, "id", "") or ""),
@@ -3087,9 +3105,11 @@ class WhatsAppClient(ClientBase):
) )
matched = False matched = False
for candidate in candidates: for candidate in candidates:
candidate_local = str(self._jid_to_identifier(candidate) or "").split( candidate_local = (
"@", 1 str(self._jid_to_identifier(candidate) or "")
)[0].strip() .split("@", 1)[0]
.strip()
)
if candidate_local and candidate_local == local: if candidate_local and candidate_local == local:
matched = True matched = True
break break
@@ -3124,7 +3144,12 @@ class WhatsAppClient(ClientBase):
# WhatsApp group ids are numeric and usually very long (commonly start # WhatsApp group ids are numeric and usually very long (commonly start
# with 120...). Treat those as groups when no explicit mapping exists. # with 120...). Treat those as groups when no explicit mapping exists.
digits = re.sub(r"[^0-9]", "", local) digits = re.sub(r"[^0-9]", "", local)
if digits and digits == local and len(digits) >= 15 and digits.startswith("120"): if (
digits
and digits == local
and len(digits) >= 15
and digits.startswith("120")
):
return f"{digits}@g.us" return f"{digits}@g.us"
return "" return ""
@@ -3264,7 +3289,9 @@ class WhatsAppClient(ClientBase):
person_identifier = await sync_to_async( person_identifier = await sync_to_async(
lambda: ( lambda: (
Message.objects.filter(id=legacy_message_id) Message.objects.filter(id=legacy_message_id)
.select_related("session__identifier__user", "session__identifier__person") .select_related(
"session__identifier__user", "session__identifier__person"
)
.first() .first()
) )
)() )()
@@ -3274,7 +3301,9 @@ class WhatsAppClient(ClientBase):
) )
if ( if (
person_identifier is not None person_identifier is not None
and str(getattr(person_identifier, "service", "") or "").strip().lower() and str(getattr(person_identifier, "service", "") or "")
.strip()
.lower()
!= "whatsapp" != "whatsapp"
): ):
person_identifier = None person_identifier = None
@@ -3418,6 +3447,8 @@ class WhatsAppClient(ClientBase):
from neonize.proto.waE2E.WAWebProtobufsE2E_pb2 import ( from neonize.proto.waE2E.WAWebProtobufsE2E_pb2 import (
ContextInfo, ContextInfo,
ExtendedTextMessage, ExtendedTextMessage,
)
from neonize.proto.waE2E.WAWebProtobufsE2E_pb2 import (
Message as WAProtoMessage, Message as WAProtoMessage,
) )

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,9 @@ def chunk_for_transport(text: str, limit: int = 3000) -> list[str]:
return [part for part in parts if part] return [part for part in parts if part]
async def post_status_in_source(trigger_message: Message, text: str, origin_tag: str) -> bool: async def post_status_in_source(
trigger_message: Message, text: str, origin_tag: str
) -> bool:
service = str(trigger_message.source_service or "").strip().lower() service = str(trigger_message.source_service or "").strip().lower()
if service not in STATUS_VISIBLE_SOURCE_SERVICES: if service not in STATUS_VISIBLE_SOURCE_SERVICES:
return False return False
@@ -76,9 +78,10 @@ async def post_to_channel_binding(
channel_identifier = str(binding_channel_identifier or "").strip() channel_identifier = str(binding_channel_identifier or "").strip()
if service == "web": if service == "web":
session = None session = None
if channel_identifier and channel_identifier == str( if (
trigger_message.source_chat_id or "" channel_identifier
).strip(): and channel_identifier == str(trigger_message.source_chat_id or "").strip()
):
session = trigger_message.session session = trigger_message.session
if session is None and channel_identifier: if session is None and channel_identifier:
session = await sync_to_async( session = await sync_to_async(
@@ -99,7 +102,8 @@ async def post_to_channel_binding(
ts=int(time.time() * 1000), ts=int(time.time() * 1000),
custom_author="BOT", custom_author="BOT",
source_service="web", source_service="web",
source_chat_id=channel_identifier or str(trigger_message.source_chat_id or ""), source_chat_id=channel_identifier
or str(trigger_message.source_chat_id or ""),
message_meta={"origin_tag": origin_tag}, message_meta={"origin_tag": origin_tag},
) )
return True return True

View File

@@ -58,9 +58,15 @@ def _effective_bootstrap_scope(
identifier = str(ctx.channel_identifier or "").strip() identifier = str(ctx.channel_identifier or "").strip()
if service != "web": if service != "web":
return service, identifier return service, identifier
session_identifier = getattr(getattr(trigger_message, "session", None), "identifier", None) session_identifier = getattr(
fallback_service = str(getattr(session_identifier, "service", "") or "").strip().lower() getattr(trigger_message, "session", None), "identifier", None
fallback_identifier = str(getattr(session_identifier, "identifier", "") or "").strip() )
fallback_service = (
str(getattr(session_identifier, "service", "") or "").strip().lower()
)
fallback_identifier = str(
getattr(session_identifier, "identifier", "") or ""
).strip()
if fallback_service and fallback_identifier and fallback_service != "web": if fallback_service and fallback_identifier and fallback_service != "web":
return fallback_service, fallback_identifier return fallback_service, fallback_identifier
return service, identifier return service, identifier
@@ -89,7 +95,11 @@ def _ensure_bp_profile(user_id: int) -> CommandProfile:
if str(profile.trigger_token or "").strip() != ".bp": if str(profile.trigger_token or "").strip() != ".bp":
profile.trigger_token = ".bp" profile.trigger_token = ".bp"
profile.save(update_fields=["trigger_token", "updated_at"]) profile.save(update_fields=["trigger_token", "updated_at"])
for action_type, position in (("extract_bp", 0), ("save_document", 1), ("post_result", 2)): for action_type, position in (
("extract_bp", 0),
("save_document", 1),
("post_result", 2),
):
action, created = CommandAction.objects.get_or_create( action, created = CommandAction.objects.get_or_create(
profile=profile, profile=profile,
action_type=action_type, action_type=action_type,
@@ -327,7 +337,9 @@ async def process_inbound_message(ctx: CommandContext) -> list[CommandResult]:
return [] return []
if is_mirrored_origin(trigger_message.message_meta): if is_mirrored_origin(trigger_message.message_meta):
return [] return []
effective_service, effective_channel = _effective_bootstrap_scope(ctx, trigger_message) effective_service, effective_channel = _effective_bootstrap_scope(
ctx, trigger_message
)
security_context = CommandSecurityContext( security_context = CommandSecurityContext(
service=effective_service, service=effective_service,
channel_identifier=effective_channel, channel_identifier=effective_channel,
@@ -394,7 +406,9 @@ async def process_inbound_message(ctx: CommandContext) -> list[CommandResult]:
result = await handler.execute(ctx) result = await handler.execute(ctx)
results.append(result) results.append(result)
except Exception as exc: except Exception as exc:
log.exception("command execution failed for profile=%s: %s", profile.slug, exc) log.exception(
"command execution failed for profile=%s: %s", profile.slug, exc
)
results.append( results.append(
CommandResult( CommandResult(
ok=False, ok=False,

View File

@@ -45,14 +45,15 @@ class BPParsedCommand(dict):
return str(self.get("remainder_text") or "") return str(self.get("remainder_text") or "")
def parse_bp_subcommand(text: str) -> BPParsedCommand: def parse_bp_subcommand(text: str) -> BPParsedCommand:
body = str(text or "") body = str(text or "")
if _BP_SET_RANGE_RE.match(body): if _BP_SET_RANGE_RE.match(body):
return BPParsedCommand(command="set_range", remainder_text="") return BPParsedCommand(command="set_range", remainder_text="")
match = _BP_SET_RE.match(body) match = _BP_SET_RE.match(body)
if match: if match:
return BPParsedCommand(command="set", remainder_text=str(match.group("rest") or "").strip()) return BPParsedCommand(
command="set", remainder_text=str(match.group("rest") or "").strip()
)
return BPParsedCommand(command=None, remainder_text="") return BPParsedCommand(command=None, remainder_text="")
@@ -63,7 +64,9 @@ def bp_subcommands_enabled() -> bool:
return bool(raw) return bool(raw)
def bp_trigger_matches(message_text: str, trigger_token: str, exact_match_only: bool) -> bool: def bp_trigger_matches(
message_text: str, trigger_token: str, exact_match_only: bool
) -> bool:
body = str(message_text or "").strip() body = str(message_text or "").strip()
trigger = str(trigger_token or "").strip() trigger = str(trigger_token or "").strip()
parsed = parse_bp_subcommand(body) parsed = parse_bp_subcommand(body)
@@ -144,7 +147,8 @@ class BPCommandHandler(CommandHandler):
"enabled": True, "enabled": True,
"generation_mode": "ai" if variant_key == "bp" else "verbatim", "generation_mode": "ai" if variant_key == "bp" else "verbatim",
"send_plan_to_egress": "post_result" in action_types, "send_plan_to_egress": "post_result" in action_types,
"send_status_to_source": str(profile.visibility_mode or "") == "status_in_source", "send_status_to_source": str(profile.visibility_mode or "")
== "status_in_source",
"send_status_to_egress": False, "send_status_to_egress": False,
"store_document": True, "store_document": True,
} }
@@ -224,10 +228,14 @@ class BPCommandHandler(CommandHandler):
ts__lte=int(trigger.ts or 0), ts__lte=int(trigger.ts or 0),
) )
.order_by("ts") .order_by("ts")
.select_related("session", "session__identifier", "session__identifier__person") .select_related(
"session", "session__identifier", "session__identifier__person"
)
) )
def _annotation(self, mode: str, message_count: int, has_addendum: bool = False) -> str: def _annotation(
self, mode: str, message_count: int, has_addendum: bool = False
) -> str:
if mode == "set" and has_addendum: if mode == "set" and has_addendum:
return "Generated from 1 message + 1 addendum." return "Generated from 1 message + 1 addendum."
if message_count == 1: if message_count == 1:
@@ -291,21 +299,29 @@ class BPCommandHandler(CommandHandler):
if anchor is None: if anchor is None:
run.status = "failed" run.status = "failed"
run.error = "bp_set_range_requires_reply_target" run.error = "bp_set_range_requires_reply_target"
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)
deterministic_content = plain_text_blob(rows) deterministic_content = plain_text_blob(rows)
if not deterministic_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": if str(policy.get("generation_mode") or "verbatim") == "ai":
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"
run.error = "ai_not_configured" run.error = "ai_not_configured"
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)
prompt = [ prompt = [
{ {
@@ -329,12 +345,16 @@ class BPCommandHandler(CommandHandler):
except Exception as exc: except Exception as exc:
run.status = "failed" run.status = "failed"
run.error = f"bp_ai_failed:{exc}" run.error = f"bp_ai_failed:{exc}"
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 not content: if not content:
run.status = "failed" run.status = "failed"
run.error = "empty_ai_response" run.error = "empty_ai_response"
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)
else: else:
content = deterministic_content content = deterministic_content
@@ -360,9 +380,7 @@ class BPCommandHandler(CommandHandler):
elif anchor is not None and remainder: elif anchor is not None and remainder:
base = str(anchor.text or "").strip() or "(no text)" base = str(anchor.text or "").strip() or "(no text)"
content = ( content = (
f"{base}\n" f"{base}\n" "--- Addendum (newer message text) ---\n" f"{remainder}"
"--- Addendum (newer message text) ---\n"
f"{remainder}"
) )
source_ids.extend([str(anchor.id), str(trigger.id)]) source_ids.extend([str(anchor.id), str(trigger.id)])
has_addendum = True has_addendum = True
@@ -373,15 +391,21 @@ class BPCommandHandler(CommandHandler):
else: else:
run.status = "failed" run.status = "failed"
run.error = "bp_set_empty_content" run.error = "bp_set_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": if str(policy.get("generation_mode") or "verbatim") == "ai":
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"
run.error = "ai_not_configured" run.error = "ai_not_configured"
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)
prompt = [ prompt = [
{ {
@@ -405,16 +429,22 @@ class BPCommandHandler(CommandHandler):
except Exception as exc: except Exception as exc:
run.status = "failed" run.status = "failed"
run.error = f"bp_ai_failed:{exc}" run.error = f"bp_ai_failed:{exc}"
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 not ai_content: if not ai_content:
run.status = "failed" run.status = "failed"
run.error = "empty_ai_response" run.error = "empty_ai_response"
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)
content = ai_content 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 doc = None
if bool(policy.get("store_document", True)): if bool(policy.get("store_document", True)):
doc = await self._persist_document( doc = await self._persist_document(
@@ -430,7 +460,9 @@ class BPCommandHandler(CommandHandler):
else: else:
run.status = "failed" run.status = "failed"
run.error = "bp_unknown_subcommand" run.error = "bp_unknown_subcommand"
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)
fanout_stats = {"sent_bindings": 0, "failed_bindings": 0} fanout_stats = {"sent_bindings": 0, "failed_bindings": 0}
@@ -479,7 +511,9 @@ class BPCommandHandler(CommandHandler):
if trigger.reply_to_id is None: if trigger.reply_to_id is None:
run.status = "failed" run.status = "failed"
run.error = "bp_requires_reply_target" run.error = "bp_requires_reply_target"
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)
anchor = trigger.reply_to anchor = trigger.reply_to
@@ -488,7 +522,9 @@ class BPCommandHandler(CommandHandler):
rows, rows,
author_rewrites={"USER": "Operator", "BOT": "Assistant"}, author_rewrites={"USER": "Operator", "BOT": "Assistant"},
) )
max_transcript_chars = int(getattr(settings, "BP_MAX_TRANSCRIPT_CHARS", 12000) or 12000) max_transcript_chars = int(
getattr(settings, "BP_MAX_TRANSCRIPT_CHARS", 12000) or 12000
)
transcript = _clamp_transcript(transcript, max_transcript_chars) transcript = _clamp_transcript(transcript, max_transcript_chars)
default_template = ( default_template = (
"Business Plan:\n" "Business Plan:\n"
@@ -499,7 +535,9 @@ class BPCommandHandler(CommandHandler):
"- Risks" "- Risks"
) )
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") generation_mode = str(policy.get("generation_mode") or "ai")
if generation_mode == "verbatim": if generation_mode == "verbatim":
@@ -507,14 +545,20 @@ class BPCommandHandler(CommandHandler):
if not summary.strip(): if not summary.strip():
run.status = "failed" run.status = "failed"
run.error = "bp_verbatim_empty_content" run.error = "bp_verbatim_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)
else: 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"
run.error = "ai_not_configured" run.error = "ai_not_configured"
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)
prompt = [ prompt = [
@@ -530,13 +574,20 @@ class BPCommandHandler(CommandHandler):
}, },
] ]
try: try:
summary = str(await ai_runner.run_prompt(prompt, ai_obj, operation="command_bp_extract") or "").strip() summary = str(
await ai_runner.run_prompt(
prompt, ai_obj, operation="command_bp_extract"
)
or ""
).strip()
if not summary: if not summary:
raise RuntimeError("empty_ai_response") raise RuntimeError("empty_ai_response")
except Exception as exc: except Exception as exc:
run.status = "failed" run.status = "failed"
run.error = f"bp_ai_failed:{exc}" run.error = f"bp_ai_failed:{exc}"
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)
annotation = self._annotation("legacy", len(rows)) annotation = self._annotation("legacy", len(rows))
@@ -588,23 +639,31 @@ class BPCommandHandler(CommandHandler):
async def execute(self, ctx: CommandContext) -> CommandResult: async def execute(self, ctx: CommandContext) -> CommandResult:
trigger = await sync_to_async( trigger = await sync_to_async(
lambda: Message.objects.select_related("user", "session").filter(id=ctx.message_id).first() lambda: Message.objects.select_related("user", "session")
.filter(id=ctx.message_id)
.first()
)() )()
if trigger is None: if trigger is None:
return CommandResult(ok=False, status="failed", error="trigger_not_found") return CommandResult(ok=False, status="failed", error="trigger_not_found")
profile = await sync_to_async( profile = await sync_to_async(
lambda: trigger.user.commandprofile_set.filter(slug=self.slug, enabled=True).first() lambda: trigger.user.commandprofile_set.filter(
slug=self.slug, enabled=True
).first()
)() )()
if profile is None: if profile is None:
return CommandResult(ok=False, status="skipped", error="profile_missing") return CommandResult(ok=False, status="skipped", error="profile_missing")
actions = await sync_to_async(list)( actions = await sync_to_async(list)(
CommandAction.objects.filter(profile=profile, enabled=True).order_by("position", "id") CommandAction.objects.filter(profile=profile, enabled=True).order_by(
"position", "id"
)
) )
action_types = {row.action_type for row in actions} action_types = {row.action_type for row in actions}
if "extract_bp" not in action_types: if "extract_bp" not in action_types:
return CommandResult(ok=False, status="skipped", error="extract_bp_disabled") return CommandResult(
ok=False, status="skipped", error="extract_bp_disabled"
)
run, created = await sync_to_async(CommandRun.objects.get_or_create)( run, created = await sync_to_async(CommandRun.objects.get_or_create)(
profile=profile, profile=profile,
@@ -612,7 +671,11 @@ class BPCommandHandler(CommandHandler):
defaults={"user": trigger.user, "status": "running"}, defaults={"user": trigger.user, "status": "running"},
) )
if not created and run.status in {"ok", "running"}: if not created and run.status in {"ok", "running"}:
return CommandResult(ok=True, status="ok", payload={"document_id": str(run.result_ref_id or "")}) return CommandResult(
ok=True,
status="ok",
payload={"document_id": str(run.result_ref_id or "")},
)
run.status = "running" run.status = "running"
run.error = "" run.error = ""
@@ -627,7 +690,9 @@ class BPCommandHandler(CommandHandler):
if not bool(policy.get("enabled")): if not bool(policy.get("enabled")):
run.status = "skipped" run.status = "skipped"
run.error = f"variant_disabled:{variant_key}" run.error = f"variant_disabled:{variant_key}"
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="skipped", error=run.error) return CommandResult(ok=False, status="skipped", error=run.error)
parsed = parse_bp_subcommand(ctx.message_text) parsed = parse_bp_subcommand(ctx.message_text)

View File

@@ -20,8 +20,8 @@ from core.models import (
TaskProject, TaskProject,
TaskProviderConfig, TaskProviderConfig,
) )
from core.tasks.codex_support import channel_variants, resolve_external_chat_id
from core.tasks.codex_approval import queue_codex_event_with_pre_approval from core.tasks.codex_approval import queue_codex_event_with_pre_approval
from core.tasks.codex_support import channel_variants, resolve_external_chat_id
_CLAUDE_DEFAULT_RE = re.compile( _CLAUDE_DEFAULT_RE = re.compile(
r"^\s*(?:\.claude\b|#claude#?)(?P<body>.*)$", r"^\s*(?:\.claude\b|#claude#?)(?P<body>.*)$",
@@ -31,7 +31,9 @@ _CLAUDE_PLAN_RE = re.compile(
r"^\s*(?:\.claude\s+plan\b|#claude\s+plan#?)(?P<body>.*)$", r"^\s*(?:\.claude\s+plan\b|#claude\s+plan#?)(?P<body>.*)$",
re.IGNORECASE | re.DOTALL, re.IGNORECASE | re.DOTALL,
) )
_CLAUDE_STATUS_RE = re.compile(r"^\s*(?:\.claude\s+status\b|#claude\s+status#?)\s*$", re.IGNORECASE) _CLAUDE_STATUS_RE = re.compile(
r"^\s*(?:\.claude\s+status\b|#claude\s+status#?)\s*$", re.IGNORECASE
)
_CLAUDE_APPROVE_DENY_RE = re.compile( _CLAUDE_APPROVE_DENY_RE = re.compile(
r"^\s*(?:\.claude|#claude)\s+(?P<action>approve|deny)\s+(?P<approval_key>[A-Za-z0-9._:-]+)#?\s*$", r"^\s*(?:\.claude|#claude)\s+(?P<action>approve|deny)\s+(?P<approval_key>[A-Za-z0-9._:-]+)#?\s*$",
re.IGNORECASE, re.IGNORECASE,
@@ -83,7 +85,9 @@ def parse_claude_command(text: str) -> ClaudeParsedCommand:
return ClaudeParsedCommand(command=None, body_text="", approval_key="") return ClaudeParsedCommand(command=None, body_text="", approval_key="")
def claude_trigger_matches(message_text: str, trigger_token: str, exact_match_only: bool) -> bool: def claude_trigger_matches(
message_text: str, trigger_token: str, exact_match_only: bool
) -> bool:
body = str(message_text or "").strip() body = str(message_text or "").strip()
parsed = parse_claude_command(body) parsed = parse_claude_command(body)
if parsed.command: if parsed.command:
@@ -103,7 +107,9 @@ class ClaudeCommandHandler(CommandHandler):
async def _load_trigger(self, message_id: str) -> Message | None: async def _load_trigger(self, message_id: str) -> Message | None:
return await sync_to_async( return await sync_to_async(
lambda: Message.objects.select_related("user", "session", "session__identifier", "reply_to") lambda: Message.objects.select_related(
"user", "session", "session__identifier", "reply_to"
)
.filter(id=message_id) .filter(id=message_id)
.first() .first()
)() )()
@@ -114,11 +120,18 @@ class ClaudeCommandHandler(CommandHandler):
identifier = getattr(getattr(trigger, "session", None), "identifier", None) identifier = getattr(getattr(trigger, "session", None), "identifier", None)
fallback_service = str(getattr(identifier, "service", "") or "").strip().lower() fallback_service = str(getattr(identifier, "service", "") or "").strip().lower()
fallback_identifier = str(getattr(identifier, "identifier", "") or "").strip() fallback_identifier = str(getattr(identifier, "identifier", "") or "").strip()
if service == "web" and fallback_service and fallback_identifier and fallback_service != "web": if (
service == "web"
and fallback_service
and fallback_identifier
and fallback_service != "web"
):
return fallback_service, fallback_identifier return fallback_service, fallback_identifier
return service or "web", channel return service or "web", channel
async def _mapped_sources(self, user, service: str, channel: str) -> list[ChatTaskSource]: async def _mapped_sources(
self, user, service: str, channel: str
) -> list[ChatTaskSource]:
variants = channel_variants(service, channel) variants = channel_variants(service, channel)
if not variants: if not variants:
return [] return []
@@ -131,7 +144,9 @@ class ClaudeCommandHandler(CommandHandler):
).select_related("project", "epic") ).select_related("project", "epic")
) )
async def _linked_task_from_reply(self, user, reply_to: Message | None) -> DerivedTask | None: async def _linked_task_from_reply(
self, user, reply_to: Message | None
) -> DerivedTask | None:
if reply_to is None: if reply_to is None:
return None return None
by_origin = await sync_to_async( by_origin = await sync_to_async(
@@ -143,7 +158,9 @@ class ClaudeCommandHandler(CommandHandler):
if by_origin is not None: if by_origin is not None:
return by_origin return by_origin
return await sync_to_async( return await sync_to_async(
lambda: DerivedTask.objects.filter(user=user, events__source_message=reply_to) lambda: DerivedTask.objects.filter(
user=user, events__source_message=reply_to
)
.select_related("project", "epic") .select_related("project", "epic")
.order_by("-created_at") .order_by("-created_at")
.first() .first()
@@ -164,10 +181,14 @@ class ClaudeCommandHandler(CommandHandler):
return "" return ""
return str(m.group(1) or "").strip() return str(m.group(1) or "").strip()
async def _resolve_task(self, user, reference_code: str, reply_task: DerivedTask | None) -> DerivedTask | None: async def _resolve_task(
self, user, reference_code: str, reply_task: DerivedTask | None
) -> DerivedTask | None:
if reference_code: if reference_code:
return await sync_to_async( return await sync_to_async(
lambda: DerivedTask.objects.filter(user=user, reference_code=reference_code) lambda: DerivedTask.objects.filter(
user=user, reference_code=reference_code
)
.select_related("project", "epic") .select_related("project", "epic")
.order_by("-created_at") .order_by("-created_at")
.first() .first()
@@ -190,7 +211,9 @@ class ClaudeCommandHandler(CommandHandler):
return reply_task.project, "" return reply_task.project, ""
if project_token: if project_token:
project = await sync_to_async( project = await sync_to_async(
lambda: TaskProject.objects.filter(user=user, name__iexact=project_token).first() lambda: TaskProject.objects.filter(
user=user, name__iexact=project_token
).first()
)() )()
if project is not None: if project is not None:
return project, "" return project, ""
@@ -199,20 +222,31 @@ class ClaudeCommandHandler(CommandHandler):
mapped = await self._mapped_sources(user, service, channel) mapped = await self._mapped_sources(user, service, channel)
project_ids = sorted({str(row.project_id) for row in mapped if row.project_id}) project_ids = sorted({str(row.project_id) for row in mapped if row.project_id})
if len(project_ids) == 1: if len(project_ids) == 1:
project = next((row.project for row in mapped if str(row.project_id) == project_ids[0]), None) project = next(
(
row.project
for row in mapped
if str(row.project_id) == project_ids[0]
),
None,
)
return project, "" return project, ""
if len(project_ids) > 1: if len(project_ids) > 1:
return None, "project_required:[project:Name]" return None, "project_required:[project:Name]"
return None, "project_unresolved" return None, "project_unresolved"
async def _post_source_status(self, trigger: Message, text: str, suffix: str) -> None: async def _post_source_status(
self, trigger: Message, text: str, suffix: str
) -> None:
await post_status_in_source( await post_status_in_source(
trigger_message=trigger, trigger_message=trigger,
text=text, text=text,
origin_tag=f"claude-status:{suffix}", origin_tag=f"claude-status:{suffix}",
) )
async def _run_status(self, trigger: Message, service: str, channel: str, project: TaskProject | None) -> CommandResult: async def _run_status(
self, trigger: Message, service: str, channel: str, project: TaskProject | None
) -> CommandResult:
def _load_runs(): def _load_runs():
qs = CodexRun.objects.filter(user=trigger.user) qs = CodexRun.objects.filter(user=trigger.user)
if service: if service:
@@ -225,7 +259,9 @@ class ClaudeCommandHandler(CommandHandler):
runs = await sync_to_async(_load_runs)() runs = await sync_to_async(_load_runs)()
if not runs: if not runs:
await self._post_source_status(trigger, "[claude] no recent runs for this scope.", "empty") await self._post_source_status(
trigger, "[claude] no recent runs for this scope.", "empty"
)
return CommandResult(ok=True, status="ok", payload={"count": 0}) return CommandResult(ok=True, status="ok", payload={"count": 0})
lines = ["[claude] recent runs:"] lines = ["[claude] recent runs:"]
for row in runs: for row in runs:
@@ -249,24 +285,38 @@ class ClaudeCommandHandler(CommandHandler):
).first() ).first()
)() )()
settings_payload = dict(getattr(cfg, "settings", {}) or {}) settings_payload = dict(getattr(cfg, "settings", {}) or {})
approver_service = str(settings_payload.get("approver_service") or "").strip().lower() approver_service = (
approver_identifier = str(settings_payload.get("approver_identifier") or "").strip() str(settings_payload.get("approver_service") or "").strip().lower()
)
approver_identifier = str(
settings_payload.get("approver_identifier") or ""
).strip()
if not approver_service or not approver_identifier: if not approver_service or not approver_identifier:
return CommandResult(ok=False, status="failed", error="approver_channel_not_configured") return CommandResult(
ok=False, status="failed", error="approver_channel_not_configured"
)
if str(current_service or "").strip().lower() != approver_service or str(current_channel or "").strip() not in set( if str(current_service or "").strip().lower() != approver_service or str(
channel_variants(approver_service, approver_identifier) current_channel or ""
): ).strip() not in set(channel_variants(approver_service, approver_identifier)):
return CommandResult(ok=False, status="failed", error="approval_command_not_allowed_in_this_channel") return CommandResult(
ok=False,
status="failed",
error="approval_command_not_allowed_in_this_channel",
)
approval_key = parsed.approval_key approval_key = parsed.approval_key
request = await sync_to_async( request = await sync_to_async(
lambda: CodexPermissionRequest.objects.select_related("codex_run", "external_sync_event") lambda: CodexPermissionRequest.objects.select_related(
"codex_run", "external_sync_event"
)
.filter(user=trigger.user, approval_key=approval_key) .filter(user=trigger.user, approval_key=approval_key)
.first() .first()
)() )()
if request is None: if request is None:
return CommandResult(ok=False, status="failed", error="approval_key_not_found") return CommandResult(
ok=False, status="failed", error="approval_key_not_found"
)
now = timezone.now() now = timezone.now()
if parsed.command == "approve": if parsed.command == "approve":
@@ -283,14 +333,20 @@ class ClaudeCommandHandler(CommandHandler):
] ]
) )
if request.external_sync_event_id: if request.external_sync_event_id:
await sync_to_async(ExternalSyncEvent.objects.filter(id=request.external_sync_event_id).update)( await sync_to_async(
ExternalSyncEvent.objects.filter(
id=request.external_sync_event_id
).update
)(
status="ok", status="ok",
error="", error="",
) )
run = request.codex_run run = request.codex_run
run.status = "approved_waiting_resume" run.status = "approved_waiting_resume"
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"]
)
source_service = str(run.source_service or "") source_service = str(run.source_service or "")
source_channel = str(run.source_channel or "") source_channel = str(run.source_channel or "")
resume_payload = dict(request.resume_payload or {}) resume_payload = dict(request.resume_payload or {})
@@ -302,14 +358,18 @@ class ClaudeCommandHandler(CommandHandler):
provider_payload["source_service"] = source_service provider_payload["source_service"] = source_service
provider_payload["source_channel"] = source_channel provider_payload["source_channel"] = source_channel
event_action = resume_action event_action = resume_action
resume_idempotency_key = str(resume_payload.get("idempotency_key") or "").strip() resume_idempotency_key = str(
resume_payload.get("idempotency_key") or ""
).strip()
resume_event_key = ( resume_event_key = (
resume_idempotency_key resume_idempotency_key
if resume_idempotency_key if resume_idempotency_key
else f"{self._approval_prefix}:{approval_key}:approved" else f"{self._approval_prefix}:{approval_key}:approved"
) )
else: else:
provider_payload = dict(run.request_payload.get("provider_payload") or {}) provider_payload = dict(
run.request_payload.get("provider_payload") or {}
)
provider_payload.update( provider_payload.update(
{ {
"mode": "approval_response", "mode": "approval_response",
@@ -337,17 +397,30 @@ class ClaudeCommandHandler(CommandHandler):
"error": "", "error": "",
}, },
) )
return CommandResult(ok=True, status="ok", payload={"approval_key": approval_key, "resolution": "approved"}) return CommandResult(
ok=True,
status="ok",
payload={"approval_key": approval_key, "resolution": "approved"},
)
request.status = "denied" request.status = "denied"
request.resolved_at = now request.resolved_at = now
request.resolved_by_identifier = current_channel request.resolved_by_identifier = current_channel
request.resolution_note = "denied via claude command" request.resolution_note = "denied via claude command"
await sync_to_async(request.save)( await sync_to_async(request.save)(
update_fields=["status", "resolved_at", "resolved_by_identifier", "resolution_note"] update_fields=[
"status",
"resolved_at",
"resolved_by_identifier",
"resolution_note",
]
) )
if request.external_sync_event_id: if request.external_sync_event_id:
await sync_to_async(ExternalSyncEvent.objects.filter(id=request.external_sync_event_id).update)( await sync_to_async(
ExternalSyncEvent.objects.filter(
id=request.external_sync_event_id
).update
)(
status="failed", status="failed",
error="approval_denied", error="approval_denied",
) )
@@ -374,7 +447,11 @@ class ClaudeCommandHandler(CommandHandler):
"error": "approval_denied", "error": "approval_denied",
}, },
) )
return CommandResult(ok=True, status="ok", payload={"approval_key": approval_key, "resolution": "denied"}) return CommandResult(
ok=True,
status="ok",
payload={"approval_key": approval_key, "resolution": "denied"},
)
async def _create_submission( async def _create_submission(
self, self,
@@ -391,7 +468,9 @@ class ClaudeCommandHandler(CommandHandler):
).first() ).first()
)() )()
if cfg is None: if cfg is None:
return CommandResult(ok=False, status="failed", error="provider_disabled_or_missing") return CommandResult(
ok=False, status="failed", error="provider_disabled_or_missing"
)
service, channel = self._effective_scope(trigger) service, channel = self._effective_scope(trigger)
external_chat_id = await sync_to_async(resolve_external_chat_id)( external_chat_id = await sync_to_async(resolve_external_chat_id)(
@@ -418,7 +497,9 @@ class ClaudeCommandHandler(CommandHandler):
if mode == "plan": if mode == "plan":
anchor = trigger.reply_to anchor = trigger.reply_to
if anchor is None: if anchor is None:
return CommandResult(ok=False, status="failed", error="reply_required_for_claude_plan") return CommandResult(
ok=False, status="failed", error="reply_required_for_claude_plan"
)
rows = await sync_to_async(list)( rows = await sync_to_async(list)(
Message.objects.filter( Message.objects.filter(
user=trigger.user, user=trigger.user,
@@ -427,7 +508,9 @@ class ClaudeCommandHandler(CommandHandler):
ts__lte=int(trigger.ts or 0), ts__lte=int(trigger.ts or 0),
) )
.order_by("ts") .order_by("ts")
.select_related("session", "session__identifier", "session__identifier__person") .select_related(
"session", "session__identifier", "session__identifier__person"
)
) )
payload["reply_context"] = { payload["reply_context"] = {
"anchor_message_id": str(anchor.id), "anchor_message_id": str(anchor.id),
@@ -446,12 +529,18 @@ class ClaudeCommandHandler(CommandHandler):
source_channel=channel, source_channel=channel,
external_chat_id=external_chat_id, external_chat_id=external_chat_id,
status="waiting_approval", status="waiting_approval",
request_payload={"action": "append_update", "provider_payload": dict(payload)}, request_payload={
"action": "append_update",
"provider_payload": dict(payload),
},
result_payload={}, result_payload={},
error="", error="",
) )
payload["codex_run_id"] = str(run.id) payload["codex_run_id"] = str(run.id)
run.request_payload = {"action": "append_update", "provider_payload": dict(payload)} run.request_payload = {
"action": "append_update",
"provider_payload": dict(payload),
}
await sync_to_async(run.save)(update_fields=["request_payload", "updated_at"]) await sync_to_async(run.save)(update_fields=["request_payload", "updated_at"])
idempotency_key = f"claude_cmd:{trigger.id}:{mode}:{task.id}:{hashlib.sha1(str(body_text or '').encode('utf-8')).hexdigest()[:12]}" idempotency_key = f"claude_cmd:{trigger.id}:{mode}:{task.id}:{hashlib.sha1(str(body_text or '').encode('utf-8')).hexdigest()[:12]}"
@@ -476,20 +565,26 @@ class ClaudeCommandHandler(CommandHandler):
return CommandResult(ok=False, status="failed", error="trigger_not_found") return CommandResult(ok=False, status="failed", error="trigger_not_found")
profile = await sync_to_async( profile = await sync_to_async(
lambda: CommandProfile.objects.filter(user=trigger.user, slug=self.slug, enabled=True).first() lambda: CommandProfile.objects.filter(
user=trigger.user, slug=self.slug, enabled=True
).first()
)() )()
if profile is None: if profile is None:
return CommandResult(ok=False, status="skipped", error="profile_missing") return CommandResult(ok=False, status="skipped", error="profile_missing")
parsed = parse_claude_command(ctx.message_text) parsed = parse_claude_command(ctx.message_text)
if not parsed.command: if not parsed.command:
return CommandResult(ok=False, status="skipped", error="claude_command_not_matched") return CommandResult(
ok=False, status="skipped", error="claude_command_not_matched"
)
service, channel = self._effective_scope(trigger) service, channel = self._effective_scope(trigger)
if parsed.command == "status": if parsed.command == "status":
project = None project = None
reply_task = await self._linked_task_from_reply(trigger.user, trigger.reply_to) reply_task = await self._linked_task_from_reply(
trigger.user, trigger.reply_to
)
if reply_task is not None: if reply_task is not None:
project = reply_task.project project = reply_task.project
return await self._run_status(trigger, service, channel, project) return await self._run_status(trigger, service, channel, project)
@@ -507,7 +602,9 @@ class ClaudeCommandHandler(CommandHandler):
reply_task = await self._linked_task_from_reply(trigger.user, trigger.reply_to) reply_task = await self._linked_task_from_reply(trigger.user, trigger.reply_to)
task = await self._resolve_task(trigger.user, reference_code, reply_task) task = await self._resolve_task(trigger.user, reference_code, reply_task)
if task is None: if task is None:
return CommandResult(ok=False, status="failed", error="task_target_required") return CommandResult(
ok=False, status="failed", error="task_target_required"
)
project, project_error = await self._resolve_project( project, project_error = await self._resolve_project(
user=trigger.user, user=trigger.user,
@@ -518,7 +615,9 @@ class ClaudeCommandHandler(CommandHandler):
project_token=project_token, project_token=project_token,
) )
if project is None: if project is None:
return CommandResult(ok=False, status="failed", error=project_error or "project_unresolved") return CommandResult(
ok=False, status="failed", error=project_error or "project_unresolved"
)
mode = "plan" if parsed.command == "plan" else "default" mode = "plan" if parsed.command == "plan" else "default"
return await self._create_submission( return await self._create_submission(

View File

@@ -20,8 +20,8 @@ from core.models import (
TaskProject, TaskProject,
TaskProviderConfig, TaskProviderConfig,
) )
from core.tasks.codex_support import channel_variants, resolve_external_chat_id
from core.tasks.codex_approval import queue_codex_event_with_pre_approval from core.tasks.codex_approval import queue_codex_event_with_pre_approval
from core.tasks.codex_support import channel_variants, resolve_external_chat_id
_CODEX_DEFAULT_RE = re.compile( _CODEX_DEFAULT_RE = re.compile(
r"^\s*(?:\.codex\b|#codex#?)(?P<body>.*)$", r"^\s*(?:\.codex\b|#codex#?)(?P<body>.*)$",
@@ -31,7 +31,9 @@ _CODEX_PLAN_RE = re.compile(
r"^\s*(?:\.codex\s+plan\b|#codex\s+plan#?)(?P<body>.*)$", r"^\s*(?:\.codex\s+plan\b|#codex\s+plan#?)(?P<body>.*)$",
re.IGNORECASE | re.DOTALL, re.IGNORECASE | re.DOTALL,
) )
_CODEX_STATUS_RE = re.compile(r"^\s*(?:\.codex\s+status\b|#codex\s+status#?)\s*$", re.IGNORECASE) _CODEX_STATUS_RE = re.compile(
r"^\s*(?:\.codex\s+status\b|#codex\s+status#?)\s*$", re.IGNORECASE
)
_CODEX_APPROVE_DENY_RE = re.compile( _CODEX_APPROVE_DENY_RE = re.compile(
r"^\s*(?:\.codex|#codex)\s+(?P<action>approve|deny)\s+(?P<approval_key>[A-Za-z0-9._:-]+)#?\s*$", r"^\s*(?:\.codex|#codex)\s+(?P<action>approve|deny)\s+(?P<approval_key>[A-Za-z0-9._:-]+)#?\s*$",
re.IGNORECASE, re.IGNORECASE,
@@ -55,7 +57,6 @@ class CodexParsedCommand(dict):
return str(self.get("approval_key") or "") return str(self.get("approval_key") or "")
def parse_codex_command(text: str) -> CodexParsedCommand: def parse_codex_command(text: str) -> CodexParsedCommand:
body = str(text or "") body = str(text or "")
m = _CODEX_APPROVE_DENY_RE.match(body) m = _CODEX_APPROVE_DENY_RE.match(body)
@@ -84,7 +85,9 @@ def parse_codex_command(text: str) -> CodexParsedCommand:
return CodexParsedCommand(command=None, body_text="", approval_key="") return CodexParsedCommand(command=None, body_text="", approval_key="")
def codex_trigger_matches(message_text: str, trigger_token: str, exact_match_only: bool) -> bool: def codex_trigger_matches(
message_text: str, trigger_token: str, exact_match_only: bool
) -> bool:
body = str(message_text or "").strip() body = str(message_text or "").strip()
parsed = parse_codex_command(body) parsed = parse_codex_command(body)
if parsed.command: if parsed.command:
@@ -102,7 +105,9 @@ class CodexCommandHandler(CommandHandler):
async def _load_trigger(self, message_id: str) -> Message | None: async def _load_trigger(self, message_id: str) -> Message | None:
return await sync_to_async( return await sync_to_async(
lambda: Message.objects.select_related("user", "session", "session__identifier", "reply_to") lambda: Message.objects.select_related(
"user", "session", "session__identifier", "reply_to"
)
.filter(id=message_id) .filter(id=message_id)
.first() .first()
)() )()
@@ -113,11 +118,18 @@ class CodexCommandHandler(CommandHandler):
identifier = getattr(getattr(trigger, "session", None), "identifier", None) identifier = getattr(getattr(trigger, "session", None), "identifier", None)
fallback_service = str(getattr(identifier, "service", "") or "").strip().lower() fallback_service = str(getattr(identifier, "service", "") or "").strip().lower()
fallback_identifier = str(getattr(identifier, "identifier", "") or "").strip() fallback_identifier = str(getattr(identifier, "identifier", "") or "").strip()
if service == "web" and fallback_service and fallback_identifier and fallback_service != "web": if (
service == "web"
and fallback_service
and fallback_identifier
and fallback_service != "web"
):
return fallback_service, fallback_identifier return fallback_service, fallback_identifier
return service or "web", channel return service or "web", channel
async def _mapped_sources(self, user, service: str, channel: str) -> list[ChatTaskSource]: async def _mapped_sources(
self, user, service: str, channel: str
) -> list[ChatTaskSource]:
variants = channel_variants(service, channel) variants = channel_variants(service, channel)
if not variants: if not variants:
return [] return []
@@ -130,7 +142,9 @@ class CodexCommandHandler(CommandHandler):
).select_related("project", "epic") ).select_related("project", "epic")
) )
async def _linked_task_from_reply(self, user, reply_to: Message | None) -> DerivedTask | None: async def _linked_task_from_reply(
self, user, reply_to: Message | None
) -> DerivedTask | None:
if reply_to is None: if reply_to is None:
return None return None
by_origin = await sync_to_async( by_origin = await sync_to_async(
@@ -142,7 +156,9 @@ class CodexCommandHandler(CommandHandler):
if by_origin is not None: if by_origin is not None:
return by_origin return by_origin
return await sync_to_async( return await sync_to_async(
lambda: DerivedTask.objects.filter(user=user, events__source_message=reply_to) lambda: DerivedTask.objects.filter(
user=user, events__source_message=reply_to
)
.select_related("project", "epic") .select_related("project", "epic")
.order_by("-created_at") .order_by("-created_at")
.first() .first()
@@ -163,10 +179,14 @@ class CodexCommandHandler(CommandHandler):
return "" return ""
return str(m.group(1) or "").strip() return str(m.group(1) or "").strip()
async def _resolve_task(self, user, reference_code: str, reply_task: DerivedTask | None) -> DerivedTask | None: async def _resolve_task(
self, user, reference_code: str, reply_task: DerivedTask | None
) -> DerivedTask | None:
if reference_code: if reference_code:
return await sync_to_async( return await sync_to_async(
lambda: DerivedTask.objects.filter(user=user, reference_code=reference_code) lambda: DerivedTask.objects.filter(
user=user, reference_code=reference_code
)
.select_related("project", "epic") .select_related("project", "epic")
.order_by("-created_at") .order_by("-created_at")
.first() .first()
@@ -189,7 +209,9 @@ class CodexCommandHandler(CommandHandler):
return reply_task.project, "" return reply_task.project, ""
if project_token: if project_token:
project = await sync_to_async( project = await sync_to_async(
lambda: TaskProject.objects.filter(user=user, name__iexact=project_token).first() lambda: TaskProject.objects.filter(
user=user, name__iexact=project_token
).first()
)() )()
if project is not None: if project is not None:
return project, "" return project, ""
@@ -198,20 +220,31 @@ class CodexCommandHandler(CommandHandler):
mapped = await self._mapped_sources(user, service, channel) mapped = await self._mapped_sources(user, service, channel)
project_ids = sorted({str(row.project_id) for row in mapped if row.project_id}) project_ids = sorted({str(row.project_id) for row in mapped if row.project_id})
if len(project_ids) == 1: if len(project_ids) == 1:
project = next((row.project for row in mapped if str(row.project_id) == project_ids[0]), None) project = next(
(
row.project
for row in mapped
if str(row.project_id) == project_ids[0]
),
None,
)
return project, "" return project, ""
if len(project_ids) > 1: if len(project_ids) > 1:
return None, "project_required:[project:Name]" return None, "project_required:[project:Name]"
return None, "project_unresolved" return None, "project_unresolved"
async def _post_source_status(self, trigger: Message, text: str, suffix: str) -> None: async def _post_source_status(
self, trigger: Message, text: str, suffix: str
) -> None:
await post_status_in_source( await post_status_in_source(
trigger_message=trigger, trigger_message=trigger,
text=text, text=text,
origin_tag=f"codex-status:{suffix}", origin_tag=f"codex-status:{suffix}",
) )
async def _run_status(self, trigger: Message, service: str, channel: str, project: TaskProject | None) -> CommandResult: async def _run_status(
self, trigger: Message, service: str, channel: str, project: TaskProject | None
) -> CommandResult:
def _load_runs(): def _load_runs():
qs = CodexRun.objects.filter(user=trigger.user) qs = CodexRun.objects.filter(user=trigger.user)
if service: if service:
@@ -224,7 +257,9 @@ class CodexCommandHandler(CommandHandler):
runs = await sync_to_async(_load_runs)() runs = await sync_to_async(_load_runs)()
if not runs: if not runs:
await self._post_source_status(trigger, "[codex] no recent runs for this scope.", "empty") await self._post_source_status(
trigger, "[codex] no recent runs for this scope.", "empty"
)
return CommandResult(ok=True, status="ok", payload={"count": 0}) return CommandResult(ok=True, status="ok", payload={"count": 0})
lines = ["[codex] recent runs:"] lines = ["[codex] recent runs:"]
for row in runs: for row in runs:
@@ -243,27 +278,43 @@ class CodexCommandHandler(CommandHandler):
current_channel: str, current_channel: str,
) -> CommandResult: ) -> CommandResult:
cfg = await sync_to_async( cfg = await sync_to_async(
lambda: TaskProviderConfig.objects.filter(user=trigger.user, provider="codex_cli").first() lambda: TaskProviderConfig.objects.filter(
user=trigger.user, provider="codex_cli"
).first()
)() )()
settings_payload = dict(getattr(cfg, "settings", {}) or {}) settings_payload = dict(getattr(cfg, "settings", {}) or {})
approver_service = str(settings_payload.get("approver_service") or "").strip().lower() approver_service = (
approver_identifier = str(settings_payload.get("approver_identifier") or "").strip() str(settings_payload.get("approver_service") or "").strip().lower()
)
approver_identifier = str(
settings_payload.get("approver_identifier") or ""
).strip()
if not approver_service or not approver_identifier: if not approver_service or not approver_identifier:
return CommandResult(ok=False, status="failed", error="approver_channel_not_configured") return CommandResult(
ok=False, status="failed", error="approver_channel_not_configured"
)
if str(current_service or "").strip().lower() != approver_service or str(current_channel or "").strip() not in set( if str(current_service or "").strip().lower() != approver_service or str(
channel_variants(approver_service, approver_identifier) current_channel or ""
): ).strip() not in set(channel_variants(approver_service, approver_identifier)):
return CommandResult(ok=False, status="failed", error="approval_command_not_allowed_in_this_channel") return CommandResult(
ok=False,
status="failed",
error="approval_command_not_allowed_in_this_channel",
)
approval_key = parsed.approval_key approval_key = parsed.approval_key
request = await sync_to_async( request = await sync_to_async(
lambda: CodexPermissionRequest.objects.select_related("codex_run", "external_sync_event") lambda: CodexPermissionRequest.objects.select_related(
"codex_run", "external_sync_event"
)
.filter(user=trigger.user, approval_key=approval_key) .filter(user=trigger.user, approval_key=approval_key)
.first() .first()
)() )()
if request is None: if request is None:
return CommandResult(ok=False, status="failed", error="approval_key_not_found") return CommandResult(
ok=False, status="failed", error="approval_key_not_found"
)
now = timezone.now() now = timezone.now()
if parsed.command == "approve": if parsed.command == "approve":
@@ -280,14 +331,20 @@ class CodexCommandHandler(CommandHandler):
] ]
) )
if request.external_sync_event_id: if request.external_sync_event_id:
await sync_to_async(ExternalSyncEvent.objects.filter(id=request.external_sync_event_id).update)( await sync_to_async(
ExternalSyncEvent.objects.filter(
id=request.external_sync_event_id
).update
)(
status="ok", status="ok",
error="", error="",
) )
run = request.codex_run run = request.codex_run
run.status = "approved_waiting_resume" run.status = "approved_waiting_resume"
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"]
)
source_service = str(run.source_service or "") source_service = str(run.source_service or "")
source_channel = str(run.source_channel or "") source_channel = str(run.source_channel or "")
resume_payload = dict(request.resume_payload or {}) resume_payload = dict(request.resume_payload or {})
@@ -299,14 +356,18 @@ class CodexCommandHandler(CommandHandler):
provider_payload["source_service"] = source_service provider_payload["source_service"] = source_service
provider_payload["source_channel"] = source_channel provider_payload["source_channel"] = source_channel
event_action = resume_action event_action = resume_action
resume_idempotency_key = str(resume_payload.get("idempotency_key") or "").strip() resume_idempotency_key = str(
resume_payload.get("idempotency_key") or ""
).strip()
resume_event_key = ( resume_event_key = (
resume_idempotency_key resume_idempotency_key
if resume_idempotency_key if resume_idempotency_key
else f"codex_approval:{approval_key}:approved" else f"codex_approval:{approval_key}:approved"
) )
else: else:
provider_payload = dict(run.request_payload.get("provider_payload") or {}) provider_payload = dict(
run.request_payload.get("provider_payload") or {}
)
provider_payload.update( provider_payload.update(
{ {
"mode": "approval_response", "mode": "approval_response",
@@ -334,17 +395,30 @@ class CodexCommandHandler(CommandHandler):
"error": "", "error": "",
}, },
) )
return CommandResult(ok=True, status="ok", payload={"approval_key": approval_key, "resolution": "approved"}) return CommandResult(
ok=True,
status="ok",
payload={"approval_key": approval_key, "resolution": "approved"},
)
request.status = "denied" request.status = "denied"
request.resolved_at = now request.resolved_at = now
request.resolved_by_identifier = current_channel request.resolved_by_identifier = current_channel
request.resolution_note = "denied via command" request.resolution_note = "denied via command"
await sync_to_async(request.save)( await sync_to_async(request.save)(
update_fields=["status", "resolved_at", "resolved_by_identifier", "resolution_note"] update_fields=[
"status",
"resolved_at",
"resolved_by_identifier",
"resolution_note",
]
) )
if request.external_sync_event_id: if request.external_sync_event_id:
await sync_to_async(ExternalSyncEvent.objects.filter(id=request.external_sync_event_id).update)( await sync_to_async(
ExternalSyncEvent.objects.filter(
id=request.external_sync_event_id
).update
)(
status="failed", status="failed",
error="approval_denied", error="approval_denied",
) )
@@ -371,7 +445,11 @@ class CodexCommandHandler(CommandHandler):
"error": "approval_denied", "error": "approval_denied",
}, },
) )
return CommandResult(ok=True, status="ok", payload={"approval_key": approval_key, "resolution": "denied"}) return CommandResult(
ok=True,
status="ok",
payload={"approval_key": approval_key, "resolution": "denied"},
)
async def _create_submission( async def _create_submission(
self, self,
@@ -383,10 +461,14 @@ class CodexCommandHandler(CommandHandler):
project: TaskProject, project: TaskProject,
) -> CommandResult: ) -> CommandResult:
cfg = await sync_to_async( cfg = await sync_to_async(
lambda: TaskProviderConfig.objects.filter(user=trigger.user, provider="codex_cli", enabled=True).first() lambda: TaskProviderConfig.objects.filter(
user=trigger.user, provider="codex_cli", enabled=True
).first()
)() )()
if cfg is None: if cfg is None:
return CommandResult(ok=False, status="failed", error="provider_disabled_or_missing") return CommandResult(
ok=False, status="failed", error="provider_disabled_or_missing"
)
service, channel = self._effective_scope(trigger) service, channel = self._effective_scope(trigger)
external_chat_id = await sync_to_async(resolve_external_chat_id)( external_chat_id = await sync_to_async(resolve_external_chat_id)(
@@ -413,7 +495,9 @@ class CodexCommandHandler(CommandHandler):
if mode == "plan": if mode == "plan":
anchor = trigger.reply_to anchor = trigger.reply_to
if anchor is None: if anchor is None:
return CommandResult(ok=False, status="failed", error="reply_required_for_codex_plan") return CommandResult(
ok=False, status="failed", error="reply_required_for_codex_plan"
)
rows = await sync_to_async(list)( rows = await sync_to_async(list)(
Message.objects.filter( Message.objects.filter(
user=trigger.user, user=trigger.user,
@@ -422,7 +506,9 @@ class CodexCommandHandler(CommandHandler):
ts__lte=int(trigger.ts or 0), ts__lte=int(trigger.ts or 0),
) )
.order_by("ts") .order_by("ts")
.select_related("session", "session__identifier", "session__identifier__person") .select_related(
"session", "session__identifier", "session__identifier__person"
)
) )
payload["reply_context"] = { payload["reply_context"] = {
"anchor_message_id": str(anchor.id), "anchor_message_id": str(anchor.id),
@@ -441,12 +527,18 @@ class CodexCommandHandler(CommandHandler):
source_channel=channel, source_channel=channel,
external_chat_id=external_chat_id, external_chat_id=external_chat_id,
status="waiting_approval", status="waiting_approval",
request_payload={"action": "append_update", "provider_payload": dict(payload)}, request_payload={
"action": "append_update",
"provider_payload": dict(payload),
},
result_payload={}, result_payload={},
error="", error="",
) )
payload["codex_run_id"] = str(run.id) payload["codex_run_id"] = str(run.id)
run.request_payload = {"action": "append_update", "provider_payload": dict(payload)} run.request_payload = {
"action": "append_update",
"provider_payload": dict(payload),
}
await sync_to_async(run.save)(update_fields=["request_payload", "updated_at"]) await sync_to_async(run.save)(update_fields=["request_payload", "updated_at"])
idempotency_key = f"codex_cmd:{trigger.id}:{mode}:{task.id}:{hashlib.sha1(str(body_text or '').encode('utf-8')).hexdigest()[:12]}" idempotency_key = f"codex_cmd:{trigger.id}:{mode}:{task.id}:{hashlib.sha1(str(body_text or '').encode('utf-8')).hexdigest()[:12]}"
@@ -471,20 +563,26 @@ class CodexCommandHandler(CommandHandler):
return CommandResult(ok=False, status="failed", error="trigger_not_found") return CommandResult(ok=False, status="failed", error="trigger_not_found")
profile = await sync_to_async( profile = await sync_to_async(
lambda: CommandProfile.objects.filter(user=trigger.user, slug=self.slug, enabled=True).first() lambda: CommandProfile.objects.filter(
user=trigger.user, slug=self.slug, enabled=True
).first()
)() )()
if profile is None: if profile is None:
return CommandResult(ok=False, status="skipped", error="profile_missing") return CommandResult(ok=False, status="skipped", error="profile_missing")
parsed = parse_codex_command(ctx.message_text) parsed = parse_codex_command(ctx.message_text)
if not parsed.command: if not parsed.command:
return CommandResult(ok=False, status="skipped", error="codex_command_not_matched") return CommandResult(
ok=False, status="skipped", error="codex_command_not_matched"
)
service, channel = self._effective_scope(trigger) service, channel = self._effective_scope(trigger)
if parsed.command == "status": if parsed.command == "status":
project = None project = None
reply_task = await self._linked_task_from_reply(trigger.user, trigger.reply_to) reply_task = await self._linked_task_from_reply(
trigger.user, trigger.reply_to
)
if reply_task is not None: if reply_task is not None:
project = reply_task.project project = reply_task.project
return await self._run_status(trigger, service, channel, project) return await self._run_status(trigger, service, channel, project)
@@ -502,7 +600,9 @@ class CodexCommandHandler(CommandHandler):
reply_task = await self._linked_task_from_reply(trigger.user, trigger.reply_to) reply_task = await self._linked_task_from_reply(trigger.user, trigger.reply_to)
task = await self._resolve_task(trigger.user, reference_code, reply_task) task = await self._resolve_task(trigger.user, reference_code, reply_task)
if task is None: if task is None:
return CommandResult(ok=False, status="failed", error="task_target_required") return CommandResult(
ok=False, status="failed", error="task_target_required"
)
project, project_error = await self._resolve_project( project, project_error = await self._resolve_project(
user=trigger.user, user=trigger.user,
@@ -513,7 +613,9 @@ class CodexCommandHandler(CommandHandler):
project_token=project_token, project_token=project_token,
) )
if project is None: if project is None:
return CommandResult(ok=False, status="failed", error=project_error or "project_unresolved") return CommandResult(
ok=False, status="failed", error=project_error or "project_unresolved"
)
mode = "plan" if parsed.command == "plan" else "default" mode = "plan" if parsed.command == "plan" else "default"
return await self._create_submission( return await self._create_submission(

View File

@@ -32,7 +32,8 @@ def _legacy_defaults(profile: CommandProfile, post_result_enabled: bool) -> dict
"enabled": True, "enabled": True,
"generation_mode": "ai", "generation_mode": "ai",
"send_plan_to_egress": bool(post_result_enabled), "send_plan_to_egress": bool(post_result_enabled),
"send_status_to_source": str(profile.visibility_mode or "") == "status_in_source", "send_status_to_source": str(profile.visibility_mode or "")
== "status_in_source",
"send_status_to_egress": False, "send_status_to_egress": False,
"store_document": True, "store_document": True,
} }
@@ -56,7 +57,9 @@ def ensure_variant_policies_for_profile(
*, *,
action_rows: Iterable[CommandAction] | None = None, action_rows: Iterable[CommandAction] | None = None,
) -> dict[str, CommandVariantPolicy]: ) -> dict[str, CommandVariantPolicy]:
actions = list(action_rows) if action_rows is not None else list(profile.actions.all()) actions = (
list(action_rows) if action_rows is not None else list(profile.actions.all())
)
post_result_enabled = any( post_result_enabled = any(
row.action_type == "post_result" and bool(row.enabled) for row in actions row.action_type == "post_result" and bool(row.enabled) for row in actions
) )
@@ -91,7 +94,9 @@ def ensure_variant_policies_for_profile(
return result return result
def load_variant_policy(profile: CommandProfile, variant_key: str) -> CommandVariantPolicy | None: def load_variant_policy(
profile: CommandProfile, variant_key: str
) -> CommandVariantPolicy | None:
key = str(variant_key or "").strip() key = str(variant_key or "").strip()
if not key: if not key:
return None return None

View File

@@ -27,6 +27,7 @@ def settings_hierarchy_nav(request):
ai_models_href = reverse("ai_models") ai_models_href = reverse("ai_models")
ai_traces_href = reverse("ai_execution_log") ai_traces_href = reverse("ai_execution_log")
commands_href = reverse("command_routing") commands_href = reverse("command_routing")
business_plans_href = reverse("business_plan_inbox")
tasks_href = reverse("tasks_settings") tasks_href = reverse("tasks_settings")
translation_href = reverse("translation_settings") translation_href = reverse("translation_settings")
availability_href = reverse("availability_settings") availability_href = reverse("availability_settings")
@@ -55,6 +56,8 @@ def settings_hierarchy_nav(request):
modules_routes = { modules_routes = {
"modules_settings", "modules_settings",
"command_routing", "command_routing",
"business_plan_inbox",
"business_plan_editor",
"tasks_settings", "tasks_settings",
"translation_settings", "translation_settings",
"availability_settings", "availability_settings",
@@ -106,7 +109,12 @@ def settings_hierarchy_nav(request):
"title": "Modules", "title": "Modules",
"tabs": [ "tabs": [
_tab("Commands", commands_href, path == commands_href), _tab("Commands", commands_href, path == commands_href),
_tab("Tasks", tasks_href, path == tasks_href), _tab(
"Business Plans",
business_plans_href,
url_name in {"business_plan_inbox", "business_plan_editor"},
),
_tab("Task Automation", tasks_href, path == tasks_href),
_tab("Translation", translation_href, path == translation_href), _tab("Translation", translation_href, path == translation_href),
_tab("Availability", availability_href, path == availability_href), _tab("Availability", availability_href, path == availability_href),
], ],

View File

@@ -24,7 +24,6 @@ async def init_mysql_pool():
async def close_mysql_pool(): async def close_mysql_pool():
"""Close the MySQL connection pool properly.""" """Close the MySQL connection pool properly."""
global mysql_pool
if mysql_pool: if mysql_pool:
mysql_pool.close() mysql_pool.close()
await mysql_pool.wait_closed() await mysql_pool.wait_closed()

View File

@@ -15,8 +15,12 @@ def event_ledger_enabled() -> bool:
def event_ledger_status() -> dict: def event_ledger_status() -> dict:
return { return {
"event_ledger_dual_write": bool(getattr(settings, "EVENT_LEDGER_DUAL_WRITE", False)), "event_ledger_dual_write": bool(
"event_primary_write_path": bool(getattr(settings, "EVENT_PRIMARY_WRITE_PATH", False)), getattr(settings, "EVENT_LEDGER_DUAL_WRITE", False)
),
"event_primary_write_path": bool(
getattr(settings, "EVENT_PRIMARY_WRITE_PATH", False)
),
} }
@@ -61,9 +65,7 @@ def append_event_sync(
if not normalized_type: if not normalized_type:
raise ValueError("event_type is required") raise ValueError("event_type is required")
candidates = { candidates = {str(choice[0]) for choice in ConversationEvent.EVENT_TYPE_CHOICES}
str(choice[0]) for choice in ConversationEvent.EVENT_TYPE_CHOICES
}
if normalized_type not in candidates: if normalized_type not in candidates:
raise ValueError(f"unsupported event_type: {normalized_type}") raise ValueError(f"unsupported event_type: {normalized_type}")

View File

@@ -90,7 +90,9 @@ def project_session_from_events(session: ChatSession) -> list[dict]:
order.append(message_id) order.append(message_id)
state.ts = _safe_int(payload.get("message_ts"), _safe_int(event.ts)) state.ts = _safe_int(payload.get("message_ts"), _safe_int(event.ts))
state.text = str(payload.get("text") or state.text or "") state.text = str(payload.get("text") or state.text or "")
delivered_default = _safe_int(payload.get("delivered_ts"), _safe_int(event.ts)) delivered_default = _safe_int(
payload.get("delivered_ts"), _safe_int(event.ts)
)
if state.delivered_ts is None: if state.delivered_ts is None:
state.delivered_ts = delivered_default or None state.delivered_ts = delivered_default or None
continue continue
@@ -111,7 +113,11 @@ def project_session_from_events(session: ChatSession) -> list[dict]:
continue continue
if event_type in {"reaction_added", "reaction_removed"}: if event_type in {"reaction_added", "reaction_removed"}:
source_service = str(payload.get("source_service") or event.origin_transport or "").strip().lower() source_service = (
str(payload.get("source_service") or event.origin_transport or "")
.strip()
.lower()
)
actor = str(payload.get("actor") or event.actor_identifier or "").strip() actor = str(payload.get("actor") or event.actor_identifier or "").strip()
emoji = str(payload.get("emoji") or "").strip() emoji = str(payload.get("emoji") or "").strip()
if not source_service and not actor and not emoji: if not source_service and not actor and not emoji:
@@ -121,7 +127,9 @@ def project_session_from_events(session: ChatSession) -> list[dict]:
"source_service": source_service, "source_service": source_service,
"actor": actor, "actor": actor,
"emoji": emoji, "emoji": emoji,
"removed": bool(event_type == "reaction_removed" or payload.get("remove")), "removed": bool(
event_type == "reaction_removed" or payload.get("remove")
),
} }
output = [] output = []
@@ -135,12 +143,12 @@ def project_session_from_events(session: ChatSession) -> list[dict]:
"ts": int(state.ts or 0), "ts": int(state.ts or 0),
"text": str(state.text or ""), "text": str(state.text or ""),
"delivered_ts": ( "delivered_ts": (
int(state.delivered_ts) int(state.delivered_ts) if state.delivered_ts is not None else None
if state.delivered_ts is not None
else None
), ),
"read_ts": int(state.read_ts) if state.read_ts is not None else None, "read_ts": int(state.read_ts) if state.read_ts is not None else None,
"reactions": _normalize_reactions(list((state.reactions or {}).values())), "reactions": _normalize_reactions(
list((state.reactions or {}).values())
),
} }
) )
return output return output
@@ -182,7 +190,9 @@ def shadow_compare_session(session: ChatSession, detail_limit: int = 50) -> dict
cause_samples = {key: [] for key in cause_counts.keys()} cause_samples = {key: [] for key in cause_counts.keys()}
cause_sample_limit = min(5, max(0, int(detail_limit))) cause_sample_limit = min(5, max(0, int(detail_limit)))
def _record_detail(message_id: str, issue: str, cause: str, extra: dict | None = None): def _record_detail(
message_id: str, issue: str, cause: str, extra: dict | None = None
):
if cause in cause_counts: if cause in cause_counts:
cause_counts[cause] += 1 cause_counts[cause] += 1
row = {"message_id": message_id, "issue": issue, "cause": cause} row = {"message_id": message_id, "issue": issue, "cause": cause}
@@ -224,13 +234,10 @@ def shadow_compare_session(session: ChatSession, detail_limit: int = 50) -> dict
db_delivered_ts = db_row.get("delivered_ts") db_delivered_ts = db_row.get("delivered_ts")
projected_delivered_ts = projected.get("delivered_ts") projected_delivered_ts = projected.get("delivered_ts")
if ( if (db_delivered_ts is None) != (projected_delivered_ts is None) or (
(db_delivered_ts is None) != (projected_delivered_ts is None) db_delivered_ts is not None
or ( and projected_delivered_ts is not None
db_delivered_ts is not None and int(db_delivered_ts) != int(projected_delivered_ts)
and projected_delivered_ts is not None
and int(db_delivered_ts) != int(projected_delivered_ts)
)
): ):
counters["delivered_ts_mismatch"] += 1 counters["delivered_ts_mismatch"] += 1
_record_detail( _record_detail(
@@ -245,13 +252,10 @@ def shadow_compare_session(session: ChatSession, detail_limit: int = 50) -> dict
db_read_ts = db_row.get("read_ts") db_read_ts = db_row.get("read_ts")
projected_read_ts = projected.get("read_ts") projected_read_ts = projected.get("read_ts")
if ( if (db_read_ts is None) != (projected_read_ts is None) or (
(db_read_ts is None) != (projected_read_ts is None) db_read_ts is not None
or ( and projected_read_ts is not None
db_read_ts is not None and int(db_read_ts) != int(projected_read_ts)
and projected_read_ts is not None
and int(db_read_ts) != int(projected_read_ts)
)
): ):
counters["read_ts_mismatch"] += 1 counters["read_ts_mismatch"] += 1
_record_detail( _record_detail(
@@ -264,12 +268,19 @@ def shadow_compare_session(session: ChatSession, detail_limit: int = 50) -> dict
db_reactions = _normalize_reactions( db_reactions = _normalize_reactions(
list((db_row.get("receipt_payload") or {}).get("reactions") or []) list((db_row.get("receipt_payload") or {}).get("reactions") or [])
) )
projected_reactions = _normalize_reactions(list(projected.get("reactions") or [])) projected_reactions = _normalize_reactions(
list(projected.get("reactions") or [])
)
if db_reactions != projected_reactions: if db_reactions != projected_reactions:
counters["reactions_mismatch"] += 1 counters["reactions_mismatch"] += 1
cause = "payload_normalization_gap" cause = "payload_normalization_gap"
strategy = str( strategy = str(
((db_row.get("receipt_payload") or {}).get("reaction_last_match_strategy") or "") (
(db_row.get("receipt_payload") or {}).get(
"reaction_last_match_strategy"
)
or ""
)
).strip() ).strip()
if strategy == "nearest_ts_window": if strategy == "nearest_ts_window":
cause = "ambiguous_reaction_target" cause = "ambiguous_reaction_target"

View File

@@ -1,6 +1,7 @@
from django import forms from django import forms
from django.contrib.auth.forms import UserCreationForm from django.contrib.auth.forms import UserCreationForm
from django.forms import ModelForm from django.forms import ModelForm
from mixins.restrictions import RestrictedFormMixin from mixins.restrictions import RestrictedFormMixin
from .models import ( from .models import (

View File

@@ -8,7 +8,6 @@ from asgiref.sync import sync_to_async
from core.models import GatewayCommandEvent from core.models import GatewayCommandEvent
from core.security.command_policy import CommandSecurityContext, evaluate_command_policy from core.security.command_policy import CommandSecurityContext, evaluate_command_policy
GatewayEmit = Callable[[str], None] GatewayEmit = Callable[[str], None]
GatewayHandler = Callable[["GatewayCommandContext", GatewayEmit], Awaitable[bool]] GatewayHandler = Callable[["GatewayCommandContext", GatewayEmit], Awaitable[bool]]
GatewayMatcher = Callable[[str], bool] GatewayMatcher = Callable[[str], bool]
@@ -103,7 +102,10 @@ async def dispatch_gateway_command(
emit(message) emit(message)
event.status = "blocked" event.status = "blocked"
event.error = f"{decision.code}:{decision.reason}" event.error = f"{decision.code}:{decision.reason}"
event.response_meta = {"policy_code": decision.code, "policy_reason": decision.reason} event.response_meta = {
"policy_code": decision.code,
"policy_reason": decision.reason,
}
await sync_to_async(event.save)( await sync_to_async(event.save)(
update_fields=["status", "error", "response_meta", "updated_at"] update_fields=["status", "error", "response_meta", "updated_at"]
) )
@@ -129,5 +131,7 @@ async def dispatch_gateway_command(
event.status = "ok" if handled else "ignored" event.status = "ok" if handled else "ignored"
event.response_meta = {"responses": responses} event.response_meta = {"responses": responses}
await sync_to_async(event.save)(update_fields=["status", "response_meta", "updated_at"]) await sync_to_async(event.save)(
update_fields=["status", "response_meta", "updated_at"]
)
return bool(handled) return bool(handled)

View File

@@ -4,7 +4,7 @@ from typing import Iterable
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from core.models import Message, User from core.models import Message
from core.presence import AvailabilitySignal, record_inferred_signal from core.presence import AvailabilitySignal, record_inferred_signal
from core.presence.inference import now_ms from core.presence.inference import now_ms
@@ -19,7 +19,9 @@ class Command(BaseCommand):
parser.add_argument("--user-id", default="") parser.add_argument("--user-id", default="")
parser.add_argument("--dry-run", action="store_true", default=False) parser.add_argument("--dry-run", action="store_true", default=False)
def _iter_messages(self, *, days: int, limit: int, service: str, user_id: str) -> Iterable[Message]: def _iter_messages(
self, *, days: int, limit: int, service: str, user_id: str
) -> Iterable[Message]:
cutoff_ts = now_ms() - (max(1, int(days)) * 24 * 60 * 60 * 1000) cutoff_ts = now_ms() - (max(1, int(days)) * 24 * 60 * 60 * 1000)
qs = Message.objects.filter(ts__gte=cutoff_ts).select_related( qs = Message.objects.filter(ts__gte=cutoff_ts).select_related(
"user", "session", "session__identifier", "session__identifier__person" "user", "session", "session__identifier", "session__identifier__person"
@@ -40,7 +42,9 @@ class Command(BaseCommand):
created = 0 created = 0
scanned = 0 scanned = 0
for msg in self._iter_messages(days=days, limit=limit, service=service_filter, user_id=user_filter): for msg in self._iter_messages(
days=days, limit=limit, service=service_filter, user_id=user_filter
):
scanned += 1 scanned += 1
identifier = getattr(getattr(msg, "session", None), "identifier", None) identifier = getattr(getattr(msg, "session", None), "identifier", None)
person = getattr(identifier, "person", None) person = getattr(identifier, "person", None)
@@ -48,12 +52,18 @@ class Command(BaseCommand):
if not identifier or not person or not user: if not identifier or not person or not user:
continue continue
service = str(getattr(msg, "source_service", "") or identifier.service or "").strip().lower() service = (
str(getattr(msg, "source_service", "") or identifier.service or "")
.strip()
.lower()
)
if not service: if not service:
continue continue
base_ts = int(getattr(msg, "ts", 0) or 0) base_ts = int(getattr(msg, "ts", 0) or 0)
message_author = str(getattr(msg, "custom_author", "") or "").strip().upper() message_author = (
str(getattr(msg, "custom_author", "") or "").strip().upper()
)
outgoing = message_author in {"USER", "BOT"} outgoing = message_author in {"USER", "BOT"}
candidates = [] candidates = []
@@ -84,7 +94,9 @@ class Command(BaseCommand):
"origin": "backfill_contact_availability", "origin": "backfill_contact_availability",
"message_id": str(msg.id), "message_id": str(msg.id),
"inferred_from": "read_receipt", "inferred_from": "read_receipt",
"read_by": str(getattr(msg, "read_by_identifier", "") or ""), "read_by": str(
getattr(msg, "read_by_identifier", "") or ""
),
}, },
} }
) )

View File

@@ -7,7 +7,12 @@ from asgiref.sync import async_to_sync
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from core.clients.transport import send_message_raw from core.clients.transport import send_message_raw
from core.models import CodexPermissionRequest, CodexRun, ExternalSyncEvent, TaskProviderConfig from core.models import (
CodexPermissionRequest,
CodexRun,
ExternalSyncEvent,
TaskProviderConfig,
)
from core.tasks.providers import get_provider from core.tasks.providers import get_provider
from core.util import logs from core.util import logs
@@ -15,7 +20,9 @@ log = logs.get_logger("codex_worker")
class Command(BaseCommand): class Command(BaseCommand):
help = "Process queued external sync events for worker-backed providers (codex_cli)." help = (
"Process queued external sync events for worker-backed providers (codex_cli)."
)
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument("--once", action="store_true", default=False) parser.add_argument("--once", action="store_true", default=False)
@@ -73,7 +80,9 @@ class Command(BaseCommand):
payload = dict(event.payload or {}) payload = dict(event.payload or {})
action = str(payload.get("action") or "append_update").strip().lower() action = str(payload.get("action") or "append_update").strip().lower()
provider_payload = dict(payload.get("provider_payload") or payload) provider_payload = dict(payload.get("provider_payload") or payload)
run_id = str(provider_payload.get("codex_run_id") or payload.get("codex_run_id") or "").strip() run_id = str(
provider_payload.get("codex_run_id") or payload.get("codex_run_id") or ""
).strip()
codex_run = None codex_run = None
if run_id: if run_id:
codex_run = CodexRun.objects.filter(id=run_id, user=event.user).first() codex_run = CodexRun.objects.filter(id=run_id, user=event.user).first()
@@ -104,9 +113,13 @@ class Command(BaseCommand):
result_payload = dict(result.payload or {}) result_payload = dict(result.payload or {})
requires_approval = bool(result_payload.get("requires_approval")) requires_approval = bool(result_payload.get("requires_approval"))
if requires_approval: if requires_approval:
approval_key = str(result_payload.get("approval_key") or uuid.uuid4().hex[:12]).strip() approval_key = str(
result_payload.get("approval_key") or uuid.uuid4().hex[:12]
).strip()
permission_request = dict(result_payload.get("permission_request") or {}) permission_request = dict(result_payload.get("permission_request") or {})
summary = str(result_payload.get("summary") or permission_request.get("summary") or "").strip() summary = str(
result_payload.get("summary") or permission_request.get("summary") or ""
).strip()
requested_permissions = permission_request.get("requested_permissions") requested_permissions = permission_request.get("requested_permissions")
if not isinstance(requested_permissions, (list, dict)): if not isinstance(requested_permissions, (list, dict)):
requested_permissions = permission_request or {} requested_permissions = permission_request or {}
@@ -121,28 +134,42 @@ class Command(BaseCommand):
codex_run.status = "waiting_approval" codex_run.status = "waiting_approval"
codex_run.result_payload = dict(result_payload) codex_run.result_payload = dict(result_payload)
codex_run.error = "" codex_run.error = ""
codex_run.save(update_fields=["status", "result_payload", "error", "updated_at"]) codex_run.save(
update_fields=["status", "result_payload", "error", "updated_at"]
)
CodexPermissionRequest.objects.update_or_create( CodexPermissionRequest.objects.update_or_create(
approval_key=approval_key, approval_key=approval_key,
defaults={ defaults={
"user": event.user, "user": event.user,
"codex_run": codex_run if codex_run is not None else CodexRun.objects.create( "codex_run": (
user=event.user, codex_run
task=event.task, if codex_run is not None
derived_task_event=event.task_event, else CodexRun.objects.create(
source_service=str(provider_payload.get("source_service") or ""), user=event.user,
source_channel=str(provider_payload.get("source_channel") or ""), task=event.task,
external_chat_id=str(provider_payload.get("external_chat_id") or ""), derived_task_event=event.task_event,
status="waiting_approval", source_service=str(
request_payload=dict(payload or {}), provider_payload.get("source_service") or ""
result_payload=dict(result_payload), ),
error="", source_channel=str(
provider_payload.get("source_channel") or ""
),
external_chat_id=str(
provider_payload.get("external_chat_id") or ""
),
status="waiting_approval",
request_payload=dict(payload or {}),
result_payload=dict(result_payload),
error="",
)
), ),
"external_sync_event": event, "external_sync_event": event,
"summary": summary, "summary": summary,
"requested_permissions": requested_permissions if isinstance(requested_permissions, dict) else { "requested_permissions": (
"items": list(requested_permissions or []) requested_permissions
}, if isinstance(requested_permissions, dict)
else {"items": list(requested_permissions or [])}
),
"resume_payload": dict(resume_payload or {}), "resume_payload": dict(resume_payload or {}),
"status": "pending", "status": "pending",
"resolved_at": None, "resolved_at": None,
@@ -150,9 +177,17 @@ class Command(BaseCommand):
"resolution_note": "", "resolution_note": "",
}, },
) )
approver_service = str((cfg.settings or {}).get("approver_service") or "").strip().lower() approver_service = (
approver_identifier = str((cfg.settings or {}).get("approver_identifier") or "").strip() str((cfg.settings or {}).get("approver_service") or "").strip().lower()
requested_text = result_payload.get("permission_request") or result_payload.get("requested_permissions") or {} )
approver_identifier = str(
(cfg.settings or {}).get("approver_identifier") or ""
).strip()
requested_text = (
result_payload.get("permission_request")
or result_payload.get("requested_permissions")
or {}
)
if approver_service and approver_identifier: if approver_service and approver_identifier:
try: try:
async_to_sync(send_message_raw)( async_to_sync(send_message_raw)(
@@ -168,10 +203,17 @@ class Command(BaseCommand):
metadata={"origin_tag": f"codex-approval:{approval_key}"}, metadata={"origin_tag": f"codex-approval:{approval_key}"},
) )
except Exception: except Exception:
log.exception("failed to notify approver channel for approval_key=%s", approval_key) log.exception(
"failed to notify approver channel for approval_key=%s",
approval_key,
)
else: else:
source_service = str(provider_payload.get("source_service") or "").strip().lower() source_service = (
source_channel = str(provider_payload.get("source_channel") or "").strip() str(provider_payload.get("source_service") or "").strip().lower()
)
source_channel = str(
provider_payload.get("source_channel") or ""
).strip()
if source_service and source_channel: if source_service and source_channel:
try: try:
async_to_sync(send_message_raw)( async_to_sync(send_message_raw)(
@@ -185,7 +227,9 @@ class Command(BaseCommand):
metadata={"origin_tag": "codex-approval-missing-target"}, metadata={"origin_tag": "codex-approval-missing-target"},
) )
except Exception: except Exception:
log.exception("failed to notify source channel for missing approver target") log.exception(
"failed to notify source channel for missing approver target"
)
return return
event.status = "ok" if result.ok else "failed" event.status = "ok" if result.ok else "failed"
@@ -201,18 +245,24 @@ class Command(BaseCommand):
approval_key = str(provider_payload.get("approval_key") or "").strip() approval_key = str(provider_payload.get("approval_key") or "").strip()
if mode == "approval_response" and approval_key: if mode == "approval_response" and approval_key:
req = ( req = (
CodexPermissionRequest.objects.select_related("external_sync_event", "codex_run") CodexPermissionRequest.objects.select_related(
"external_sync_event", "codex_run"
)
.filter(user=event.user, approval_key=approval_key) .filter(user=event.user, approval_key=approval_key)
.first() .first()
) )
if req and req.external_sync_event_id: if req and req.external_sync_event_id:
if result.ok: if result.ok:
ExternalSyncEvent.objects.filter(id=req.external_sync_event_id).update( ExternalSyncEvent.objects.filter(
id=req.external_sync_event_id
).update(
status="ok", status="ok",
error="", error="",
) )
elif str(event.error or "").strip() == "approval_denied": elif str(event.error or "").strip() == "approval_denied":
ExternalSyncEvent.objects.filter(id=req.external_sync_event_id).update( ExternalSyncEvent.objects.filter(
id=req.external_sync_event_id
).update(
status="failed", status="failed",
error="approval_denied", error="approval_denied",
) )
@@ -220,9 +270,16 @@ class Command(BaseCommand):
codex_run.status = "ok" if result.ok else "failed" codex_run.status = "ok" if result.ok else "failed"
codex_run.error = str(result.error or "") codex_run.error = str(result.error or "")
codex_run.result_payload = result_payload codex_run.result_payload = result_payload
codex_run.save(update_fields=["status", "error", "result_payload", "updated_at"]) codex_run.save(
update_fields=["status", "error", "result_payload", "updated_at"]
)
if result.ok and result.external_key and event.task_id and not str(event.task.external_key or "").strip(): if (
result.ok
and result.external_key
and event.task_id
and not str(event.task.external_key or "").strip()
):
event.task.external_key = str(result.external_key) event.task.external_key = str(result.external_key)
event.task.save(update_fields=["external_key"]) event.task.save(update_fields=["external_key"])
@@ -250,7 +307,11 @@ class Command(BaseCommand):
continue continue
for row_id in claimed_ids: for row_id in claimed_ids:
event = ExternalSyncEvent.objects.filter(id=row_id).select_related("task", "user").first() event = (
ExternalSyncEvent.objects.filter(id=row_id)
.select_related("task", "user")
.first()
)
if event is None: if event is None:
continue continue
try: try:

View File

@@ -85,7 +85,9 @@ class Command(BaseCommand):
compared = shadow_compare_session(session, detail_limit=detail_limit) compared = shadow_compare_session(session, detail_limit=detail_limit)
aggregate["sessions_scanned"] += 1 aggregate["sessions_scanned"] += 1
aggregate["db_message_count"] += int(compared.get("db_message_count") or 0) aggregate["db_message_count"] += int(compared.get("db_message_count") or 0)
aggregate["projected_message_count"] += int(compared.get("projected_message_count") or 0) aggregate["projected_message_count"] += int(
compared.get("projected_message_count") or 0
)
aggregate["mismatch_total"] += int(compared.get("mismatch_total") or 0) aggregate["mismatch_total"] += int(compared.get("mismatch_total") or 0)
for key in aggregate["counters"].keys(): for key in aggregate["counters"].keys():
aggregate["counters"][key] += int( aggregate["counters"][key] += int(

View File

@@ -1,14 +1,11 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from core.models import ContactAvailabilityEvent, ContactAvailabilitySpan, Message from core.models import ContactAvailabilityEvent, ContactAvailabilitySpan, Message
from core.presence import AvailabilitySignal, record_native_signal from core.presence import AvailabilitySignal, record_native_signal
from core.presence.inference import now_ms from core.presence.inference import now_ms
_SOURCE_ORDER = { _SOURCE_ORDER = {
"message_in": 10, "message_in": 10,
"message_out": 20, "message_out": 20,
@@ -51,9 +48,14 @@ class Command(BaseCommand):
if not identifier or not person or not user: if not identifier or not person or not user:
continue continue
service = str( service = (
getattr(msg, "source_service", "") or getattr(identifier, "service", "") str(
).strip().lower() getattr(msg, "source_service", "")
or getattr(identifier, "service", "")
)
.strip()
.lower()
)
if not service: if not service:
continue continue
@@ -95,12 +97,16 @@ class Command(BaseCommand):
"origin": "recalculate_contact_availability", "origin": "recalculate_contact_availability",
"message_id": str(msg.id), "message_id": str(msg.id),
"inferred_from": "read_receipt", "inferred_from": "read_receipt",
"read_by": str(getattr(msg, "read_by_identifier", "") or ""), "read_by": str(
getattr(msg, "read_by_identifier", "") or ""
),
}, },
} }
) )
reactions = list((getattr(msg, "receipt_payload", {}) or {}).get("reactions") or []) reactions = list(
(getattr(msg, "receipt_payload", {}) or {}).get("reactions") or []
)
for reaction in reactions: for reaction in reactions:
item = dict(reaction or {}) item = dict(reaction or {})
if bool(item.get("removed")): if bool(item.get("removed")):
@@ -124,7 +130,9 @@ class Command(BaseCommand):
"inferred_from": "reaction", "inferred_from": "reaction",
"emoji": str(item.get("emoji") or ""), "emoji": str(item.get("emoji") or ""),
"actor": str(item.get("actor") or ""), "actor": str(item.get("actor") or ""),
"source_service": str(item.get("source_service") or service), "source_service": str(
item.get("source_service") or service
),
}, },
} }
) )

View File

@@ -67,7 +67,9 @@ def _compute_payload(rows, identifier_values):
pending_out_ts = None pending_out_ts = None
first_ts = int(rows[0]["ts"] or 0) first_ts = int(rows[0]["ts"] or 0)
last_ts = int(rows[-1]["ts"] or 0) last_ts = int(rows[-1]["ts"] or 0)
latest_service = str(rows[-1].get("session__identifier__service") or "").strip().lower() latest_service = (
str(rows[-1].get("session__identifier__service") or "").strip().lower()
)
for row in rows: for row in rows:
ts = int(row.get("ts") or 0) ts = int(row.get("ts") or 0)
@@ -162,18 +164,18 @@ def _compute_payload(rows, identifier_values):
payload = { payload = {
"source_event_ts": last_ts, "source_event_ts": last_ts,
"stability_state": stability_state, "stability_state": stability_state,
"stability_score": float(stability_score_value) "stability_score": (
if stability_score_value is not None float(stability_score_value) if stability_score_value is not None else None
else None, ),
"stability_confidence": round(confidence, 3), "stability_confidence": round(confidence, 3),
"stability_sample_messages": message_count, "stability_sample_messages": message_count,
"stability_sample_days": sample_days, "stability_sample_days": sample_days,
"commitment_inbound_score": float(commitment_in_value) "commitment_inbound_score": (
if commitment_in_value is not None float(commitment_in_value) if commitment_in_value is not None else None
else None, ),
"commitment_outbound_score": float(commitment_out_value) "commitment_outbound_score": (
if commitment_out_value is not None float(commitment_out_value) if commitment_out_value is not None else None
else None, ),
"commitment_confidence": round(confidence, 3), "commitment_confidence": round(confidence, 3),
"inbound_messages": inbound_count, "inbound_messages": inbound_count,
"outbound_messages": outbound_count, "outbound_messages": outbound_count,
@@ -232,15 +234,17 @@ class Command(BaseCommand):
dry_run = bool(options.get("dry_run")) dry_run = bool(options.get("dry_run"))
reset = not bool(options.get("no_reset")) reset = not bool(options.get("no_reset"))
compact_enabled = not bool(options.get("skip_compact")) compact_enabled = not bool(options.get("skip_compact"))
today_start = dj_timezone.now().astimezone(timezone.utc).replace( today_start = (
hour=0, dj_timezone.now()
minute=0, .astimezone(timezone.utc)
second=0, .replace(
microsecond=0, hour=0,
) minute=0,
cutoff_ts = int( second=0,
(today_start.timestamp() * 1000) - (days * 24 * 60 * 60 * 1000) microsecond=0,
)
) )
cutoff_ts = int((today_start.timestamp() * 1000) - (days * 24 * 60 * 60 * 1000))
people_qs = Person.objects.all() people_qs = Person.objects.all()
if user_id: if user_id:
@@ -256,14 +260,18 @@ class Command(BaseCommand):
compacted_deleted = 0 compacted_deleted = 0
for person in people: for person in people:
identifiers_qs = PersonIdentifier.objects.filter(user=person.user, person=person) identifiers_qs = PersonIdentifier.objects.filter(
user=person.user, person=person
)
if service: if service:
identifiers_qs = identifiers_qs.filter(service=service) identifiers_qs = identifiers_qs.filter(service=service)
identifiers = list(identifiers_qs) identifiers = list(identifiers_qs)
if not identifiers: if not identifiers:
continue continue
identifier_values = { identifier_values = {
str(row.identifier or "").strip() for row in identifiers if row.identifier str(row.identifier or "").strip()
for row in identifiers
if row.identifier
} }
if not identifier_values: if not identifier_values:
continue continue
@@ -350,7 +358,9 @@ class Command(BaseCommand):
snapshots_created += 1 snapshots_created += 1
if dry_run: if dry_run:
continue continue
WorkspaceMetricSnapshot.objects.create(conversation=conversation, **payload) WorkspaceMetricSnapshot.objects.create(
conversation=conversation, **payload
)
existing_signatures.add(signature) existing_signatures.add(signature)
if not latest_payload: if not latest_payload:
@@ -368,7 +378,9 @@ class Command(BaseCommand):
"updated_at": dj_timezone.now().isoformat(), "updated_at": dj_timezone.now().isoformat(),
} }
if not dry_run: if not dry_run:
conversation.platform_type = latest_service or conversation.platform_type conversation.platform_type = (
latest_service or conversation.platform_type
)
conversation.last_event_ts = latest_payload.get("source_event_ts") conversation.last_event_ts = latest_payload.get("source_event_ts")
conversation.stability_state = str( conversation.stability_state = str(
latest_payload.get("stability_state") latest_payload.get("stability_state")
@@ -416,7 +428,9 @@ class Command(BaseCommand):
) )
if compact_enabled: if compact_enabled:
snapshot_rows = list( snapshot_rows = list(
WorkspaceMetricSnapshot.objects.filter(conversation=conversation) WorkspaceMetricSnapshot.objects.filter(
conversation=conversation
)
.order_by("computed_at", "id") .order_by("computed_at", "id")
.values("id", "computed_at", "source_event_ts") .values("id", "computed_at", "source_event_ts")
) )
@@ -428,7 +442,9 @@ class Command(BaseCommand):
) )
if keep_ids: if keep_ids:
compacted_deleted += ( compacted_deleted += (
WorkspaceMetricSnapshot.objects.filter(conversation=conversation) WorkspaceMetricSnapshot.objects.filter(
conversation=conversation
)
.exclude(id__in=list(keep_ids)) .exclude(id__in=list(keep_ids))
.delete()[0] .delete()[0]
) )

View File

@@ -4,4 +4,6 @@ from core.management.commands.codex_worker import Command as LegacyCodexWorkerCo
class Command(LegacyCodexWorkerCommand): class Command(LegacyCodexWorkerCommand):
help = "Process queued task-sync events for worker-backed providers (Codex + Claude)." help = (
"Process queued task-sync events for worker-backed providers (Codex + Claude)."
)

View File

@@ -123,7 +123,9 @@ def _handle_message(message: dict[str, Any]) -> dict[str, Any] | None:
msg_id, msg_id,
{ {
"isError": True, "isError": True,
"content": [{"type": "text", "text": json.dumps({"error": str(exc)})}], "content": [
{"type": "text", "text": json.dumps({"error": str(exc)})}
],
}, },
) )

View File

@@ -216,7 +216,9 @@ def _next_unique_slug(*, user_id: int, requested_slug: str) -> str:
raise ValueError("slug cannot be empty") raise ValueError("slug cannot be empty")
candidate = base candidate = base
idx = 2 idx = 2
while KnowledgeArticle.objects.filter(user_id=int(user_id), slug=candidate).exists(): while KnowledgeArticle.objects.filter(
user_id=int(user_id), slug=candidate
).exists():
suffix = f"-{idx}" suffix = f"-{idx}"
candidate = f"{base[: max(1, 255 - len(suffix))]}{suffix}" candidate = f"{base[: max(1, 255 - len(suffix))]}{suffix}"
idx += 1 idx += 1
@@ -645,9 +647,7 @@ def tool_wiki_update_article(arguments: dict[str, Any]) -> dict[str, Any]:
) )
if status_marker and status == "archived" and article.status != "archived": if status_marker and status == "archived" and article.status != "archived":
if not approve_archive: if not approve_archive:
raise ValueError( raise ValueError("approve_archive=true is required to archive an article")
"approve_archive=true is required to archive an article"
)
if title: if title:
article.title = title article.title = title
@@ -705,7 +705,9 @@ def tool_wiki_list(arguments: dict[str, Any]) -> dict[str, Any]:
def tool_wiki_get(arguments: dict[str, Any]) -> dict[str, Any]: def tool_wiki_get(arguments: dict[str, Any]) -> dict[str, Any]:
article = _get_article_for_user(arguments) article = _get_article_for_user(arguments)
include_revisions = bool(arguments.get("include_revisions")) include_revisions = bool(arguments.get("include_revisions"))
revision_limit = _safe_limit(arguments.get("revision_limit"), default=20, low=1, high=200) revision_limit = _safe_limit(
arguments.get("revision_limit"), default=20, low=1, high=200
)
payload = {"article": _article_payload(article)} payload = {"article": _article_payload(article)}
if include_revisions: if include_revisions:
revisions = article.revisions.order_by("-revision")[:revision_limit] revisions = article.revisions.order_by("-revision")[:revision_limit]
@@ -714,7 +716,9 @@ def tool_wiki_get(arguments: dict[str, Any]) -> dict[str, Any]:
def tool_project_get_guidelines(arguments: dict[str, Any]) -> dict[str, Any]: def tool_project_get_guidelines(arguments: dict[str, Any]) -> dict[str, Any]:
max_chars = _safe_limit(arguments.get("max_chars"), default=16000, low=500, high=50000) max_chars = _safe_limit(
arguments.get("max_chars"), default=16000, low=500, high=50000
)
base = Path(settings.BASE_DIR).resolve() base = Path(settings.BASE_DIR).resolve()
file_names = ["AGENTS.md", "LLM_CODING_STANDARDS.md", "INSTALL.md", "README.md"] file_names = ["AGENTS.md", "LLM_CODING_STANDARDS.md", "INSTALL.md", "README.md"]
payload = [] payload = []
@@ -734,7 +738,9 @@ def tool_project_get_guidelines(arguments: dict[str, Any]) -> dict[str, Any]:
def tool_project_get_layout(arguments: dict[str, Any]) -> dict[str, Any]: def tool_project_get_layout(arguments: dict[str, Any]) -> dict[str, Any]:
max_entries = _safe_limit(arguments.get("max_entries"), default=300, low=50, high=4000) max_entries = _safe_limit(
arguments.get("max_entries"), default=300, low=50, high=4000
)
base = Path(settings.BASE_DIR).resolve() base = Path(settings.BASE_DIR).resolve()
roots = ["app", "core", "scripts", "utilities", "artifacts"] roots = ["app", "core", "scripts", "utilities", "artifacts"]
items: list[str] = [] items: list[str] = []
@@ -754,7 +760,9 @@ def tool_project_get_layout(arguments: dict[str, Any]) -> dict[str, Any]:
def tool_project_get_runbook(arguments: dict[str, Any]) -> dict[str, Any]: def tool_project_get_runbook(arguments: dict[str, Any]) -> dict[str, Any]:
max_chars = _safe_limit(arguments.get("max_chars"), default=16000, low=500, high=50000) max_chars = _safe_limit(
arguments.get("max_chars"), default=16000, low=500, high=50000
)
base = Path(settings.BASE_DIR).resolve() base = Path(settings.BASE_DIR).resolve()
file_names = [ file_names = [
"INSTALL.md", "INSTALL.md",
@@ -792,7 +800,11 @@ def tool_docs_append_run_note(arguments: dict[str, Any]) -> dict[str, Any]:
path = Path("/tmp/gia-mcp-run-notes.md") path = Path("/tmp/gia-mcp-run-notes.md")
else: else:
candidate = Path(raw_path) candidate = Path(raw_path)
path = candidate.resolve() if candidate.is_absolute() else (base / candidate).resolve() path = (
candidate.resolve()
if candidate.is_absolute()
else (base / candidate).resolve()
)
allowed_roots = [base, Path("/tmp").resolve()] allowed_roots = [base, Path("/tmp").resolve()]
if not any(str(path).startswith(str(root)) for root in allowed_roots): if not any(str(path).startswith(str(root)) for root in allowed_roots):
raise ValueError("path must be within project root or /tmp") raise ValueError("path must be within project root or /tmp")
@@ -812,7 +824,11 @@ def tool_docs_append_run_note(arguments: dict[str, Any]) -> dict[str, Any]:
TOOL_DEFS: dict[str, dict[str, Any]] = { TOOL_DEFS: dict[str, dict[str, Any]] = {
"manticore.status": { "manticore.status": {
"description": "Report configured memory backend status (django or manticore).", "description": "Report configured memory backend status (django or manticore).",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, "inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": False,
},
"handler": tool_manticore_status, "handler": tool_manticore_status,
}, },
"manticore.query": { "manticore.query": {

View File

@@ -1,4 +1,4 @@
from .search_backend import get_memory_search_backend
from .retrieval import retrieve_memories_for_prompt from .retrieval import retrieve_memories_for_prompt
from .search_backend import get_memory_search_backend
__all__ = ["get_memory_search_backend", "retrieve_memories_for_prompt"] __all__ = ["get_memory_search_backend", "retrieve_memories_for_prompt"]

View File

@@ -224,7 +224,9 @@ def create_memory_change_request(
person_id=person_id or (str(memory.person_id or "") if memory else "") or None, person_id=person_id or (str(memory.person_id or "") if memory else "") or None,
action=normalized_action, action=normalized_action,
status="pending", status="pending",
proposed_memory_kind=str(memory_kind or (memory.memory_kind if memory else "")).strip(), proposed_memory_kind=str(
memory_kind or (memory.memory_kind if memory else "")
).strip(),
proposed_content=dict(content or {}), proposed_content=dict(content or {}),
proposed_confidence_score=( proposed_confidence_score=(
float(confidence_score) float(confidence_score)
@@ -335,7 +337,9 @@ def review_memory_change_request(
@transaction.atomic @transaction.atomic
def run_memory_hygiene(*, user_id: int | None = None, dry_run: bool = False) -> dict[str, int]: def run_memory_hygiene(
*, user_id: int | None = None, dry_run: bool = False
) -> dict[str, int]:
now = timezone.now() now = timezone.now()
queryset = MemoryItem.objects.filter(status="active") queryset = MemoryItem.objects.filter(status="active")
if user_id is not None: if user_id is not None:
@@ -357,7 +361,9 @@ def run_memory_hygiene(*, user_id: int | None = None, dry_run: bool = False) ->
for item in queryset.select_related("conversation", "person"): for item in queryset.select_related("conversation", "person"):
content = item.content or {} content = item.content or {}
field = str(content.get("field") or content.get("key") or "").strip().lower() field = str(content.get("field") or content.get("key") or "").strip().lower()
text = _clean_value(str(content.get("text") or content.get("value") or "")).lower() text = _clean_value(
str(content.get("text") or content.get("value") or "")
).lower()
if not field or not text: if not field or not text:
continue continue
scope = ( scope = (

View File

@@ -59,7 +59,11 @@ def retrieve_memories_for_prompt(
limit=safe_limit, limit=safe_limit,
include_statuses=statuses, include_statuses=statuses,
) )
ids = [str(hit.memory_id or "").strip() for hit in hits if str(hit.memory_id or "").strip()] ids = [
str(hit.memory_id or "").strip()
for hit in hits
if str(hit.memory_id or "").strip()
]
scoped = _base_queryset( scoped = _base_queryset(
user_id=int(user_id), user_id=int(user_id),
person_id=person_id, person_id=person_id,
@@ -82,11 +86,17 @@ def retrieve_memories_for_prompt(
"content": item.content or {}, "content": item.content or {},
"provenance": item.provenance or {}, "provenance": item.provenance or {},
"confidence_score": float(item.confidence_score or 0.0), "confidence_score": float(item.confidence_score or 0.0),
"expires_at": item.expires_at.isoformat() if item.expires_at else "", "expires_at": (
"last_verified_at": ( item.expires_at.isoformat() if item.expires_at else ""
item.last_verified_at.isoformat() if item.last_verified_at else "" ),
"last_verified_at": (
item.last_verified_at.isoformat()
if item.last_verified_at
else ""
),
"updated_at": (
item.updated_at.isoformat() if item.updated_at else ""
), ),
"updated_at": item.updated_at.isoformat() if item.updated_at else "",
"search_score": float(hit.score or 0.0), "search_score": float(hit.score or 0.0),
"search_summary": str(hit.summary or ""), "search_summary": str(hit.summary or ""),
} }

View File

@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import json
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
@@ -144,9 +143,10 @@ class ManticoreMemorySearchBackend(BaseMemorySearchBackend):
self.base_url = str( self.base_url = str(
getattr(settings, "MANTICORE_HTTP_URL", "http://localhost:9308") getattr(settings, "MANTICORE_HTTP_URL", "http://localhost:9308")
).rstrip("/") ).rstrip("/")
self.table = str( self.table = (
getattr(settings, "MANTICORE_MEMORY_TABLE", "gia_memory_items") str(getattr(settings, "MANTICORE_MEMORY_TABLE", "gia_memory_items")).strip()
).strip() or "gia_memory_items" or "gia_memory_items"
)
self.timeout_seconds = int(getattr(settings, "MANTICORE_HTTP_TIMEOUT", 5) or 5) self.timeout_seconds = int(getattr(settings, "MANTICORE_HTTP_TIMEOUT", 5) or 5)
self._table_cache_key = f"{self.base_url}|{self.table}" self._table_cache_key = f"{self.base_url}|{self.table}"
@@ -163,7 +163,9 @@ class ManticoreMemorySearchBackend(BaseMemorySearchBackend):
return dict(payload or {}) return dict(payload or {})
def ensure_table(self) -> None: def ensure_table(self) -> None:
last_ready = float(self._table_ready_cache.get(self._table_cache_key, 0.0) or 0.0) last_ready = float(
self._table_ready_cache.get(self._table_cache_key, 0.0) or 0.0
)
if (time.time() - last_ready) <= float(self._table_ready_ttl_seconds): if (time.time() - last_ready) <= float(self._table_ready_ttl_seconds):
return return
self._sql( self._sql(
@@ -254,7 +256,9 @@ class ManticoreMemorySearchBackend(BaseMemorySearchBackend):
try: try:
values.append(self._build_upsert_values_clause(item)) values.append(self._build_upsert_values_clause(item))
except Exception as exc: except Exception as exc:
log.warning("memory-search upsert build failed id=%s err=%s", item.id, exc) log.warning(
"memory-search upsert build failed id=%s err=%s", item.id, exc
)
continue continue
if len(values) >= batch_size: if len(values) >= batch_size:
self._sql( self._sql(
@@ -290,7 +294,11 @@ class ManticoreMemorySearchBackend(BaseMemorySearchBackend):
where_parts = [f"user_id={int(user_id)}", f"MATCH('{self._escape(needle)}')"] where_parts = [f"user_id={int(user_id)}", f"MATCH('{self._escape(needle)}')"]
if conversation_id: if conversation_id:
where_parts.append(f"conversation_id='{self._escape(conversation_id)}'") where_parts.append(f"conversation_id='{self._escape(conversation_id)}'")
statuses = [str(item or "").strip() for item in include_statuses if str(item or "").strip()] statuses = [
str(item or "").strip()
for item in include_statuses
if str(item or "").strip()
]
if statuses: if statuses:
in_clause = ",".join(f"'{self._escape(item)}'" for item in statuses) in_clause = ",".join(f"'{self._escape(item)}'" for item in statuses)
where_parts.append(f"status IN ({in_clause})") where_parts.append(f"status IN ({in_clause})")

View File

@@ -1,12 +1,13 @@
from asgiref.sync import sync_to_async
from django.conf import settings
import time import time
import uuid import uuid
from asgiref.sync import sync_to_async
from django.conf import settings
from core.events.ledger import append_event from core.events.ledger import append_event
from core.messaging.utils import messages_to_string from core.messaging.utils import messages_to_string
from core.observability.tracing import ensure_trace_id
from core.models import ChatSession, Message, QueuedMessage from core.models import ChatSession, Message, QueuedMessage
from core.observability.tracing import ensure_trace_id
from core.util import logs from core.util import logs
log = logs.get_logger("history") log = logs.get_logger("history")
@@ -272,7 +273,9 @@ async def store_own_message(
trace_id=ensure_trace_id(trace_id, message_meta or {}), trace_id=ensure_trace_id(trace_id, message_meta or {}),
) )
except Exception as exc: except Exception as exc:
log.warning("Event ledger append failed for own message=%s: %s", msg.id, exc) log.warning(
"Event ledger append failed for own message=%s: %s", msg.id, exc
)
return msg return msg

View File

@@ -335,8 +335,12 @@ def extract_reply_ref(service: str, raw_payload: dict[str, Any]) -> dict[str, st
svc = _clean(service).lower() svc = _clean(service).lower()
payload = _as_dict(raw_payload) payload = _as_dict(raw_payload)
if svc == "xmpp": if svc == "xmpp":
reply_id = _clean(payload.get("reply_source_message_id") or payload.get("reply_id")) reply_id = _clean(
reply_chat = _clean(payload.get("reply_source_chat_id") or payload.get("reply_chat_id")) payload.get("reply_source_message_id") or payload.get("reply_id")
)
reply_chat = _clean(
payload.get("reply_source_chat_id") or payload.get("reply_chat_id")
)
if reply_id: if reply_id:
return { return {
"reply_source_message_id": reply_id, "reply_source_message_id": reply_id,
@@ -363,7 +367,9 @@ def extract_origin_tag(raw_payload: dict[str, Any] | None) -> str:
return _find_origin_tag(_as_dict(raw_payload)) return _find_origin_tag(_as_dict(raw_payload))
async def resolve_reply_target(user, session, reply_ref: dict[str, str]) -> Message | None: async def resolve_reply_target(
user, session, reply_ref: dict[str, str]
) -> Message | None:
if not reply_ref or session is None: if not reply_ref or session is None:
return None return None
reply_source_message_id = _clean(reply_ref.get("reply_source_message_id")) reply_source_message_id = _clean(reply_ref.get("reply_source_message_id"))

View File

@@ -1,7 +1,8 @@
# Generated by Django 5.2.11 on 2026-03-02 11:55 # Generated by Django 5.2.11 on 2026-03-02 11:55
import django.db.models.deletion
import uuid import uuid
import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models

View File

@@ -1,6 +1,6 @@
import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@@ -1,8 +1,8 @@
import uuid import uuid
import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@@ -1,8 +1,8 @@
import uuid import uuid
import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@@ -1,8 +1,8 @@
# Generated by Django 4.2.19 on 2026-03-07 00:00 # Generated by Django 4.2.19 on 2026-03-07 00:00
import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@@ -0,0 +1,39 @@
# Generated by Django 5.2.7 on 2026-03-07 20:12
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0041_useraccessibilitysettings'),
]
operations = [
migrations.CreateModel(
name='UserXmppOmemoTrustedKey',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('jid', models.CharField(blank=True, default='', max_length=255)),
('key_type', models.CharField(choices=[('fingerprint', 'Fingerprint'), ('client_key', 'Client key')], default='fingerprint', max_length=32)),
('key_id', models.CharField(max_length=255)),
('trusted', models.BooleanField(default=False)),
('source', models.CharField(blank=True, default='', max_length=64)),
('last_seen_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='xmpp_omemo_trusted_keys', to=settings.AUTH_USER_MODEL)),
],
options={
'indexes': [
models.Index(fields=['user', 'trusted', 'updated_at'], name='core_userxomemo_trusted_idx'),
models.Index(fields=['user', 'jid', 'updated_at'], name='core_userxomemo_jid_idx'),
],
'constraints': [
models.UniqueConstraint(fields=('user', 'jid', 'key_type', 'key_id'), name='unique_user_xmpp_omemo_trusted_key'),
],
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-03-07 20:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0042_userxmppomemotrustedkey_and_more"),
]
operations = [
migrations.AddField(
model_name="userxmppsecuritysettings",
name="encrypt_contact_messages_with_omemo",
field=models.BooleanField(default=True),
),
]

View File

@@ -20,14 +20,14 @@ SERVICE_CHOICES = (
) )
CHANNEL_SERVICE_CHOICES = SERVICE_CHOICES + (("web", "Web"),) CHANNEL_SERVICE_CHOICES = SERVICE_CHOICES + (("web", "Web"),)
MBTI_CHOICES = ( MBTI_CHOICES = (
("INTJ", "INTJ - Architect"),# ;) ("INTJ", "INTJ - Architect"), # ;)
("INTP", "INTP - Logician"), ("INTP", "INTP - Logician"),
("ENTJ", "ENTJ - Commander"), ("ENTJ", "ENTJ - Commander"),
("ENTP", "ENTP - Debater"), ("ENTP", "ENTP - Debater"),
("INFJ", "INFJ - Advocate"), ("INFJ", "INFJ - Advocate"),
("INFP", "INFP - Mediator"), ("INFP", "INFP - Mediator"),
("ENFJ", "ENFJ - Protagonist"), ("ENFJ", "ENFJ - Protagonist"),
("ENFP", "ENFP - Campaigner"), # <3 ("ENFP", "ENFP - Campaigner"), # <3
("ISTJ", "ISTJ - Logistician"), ("ISTJ", "ISTJ - Logistician"),
("ISFJ", "ISFJ - Defender"), ("ISFJ", "ISFJ - Defender"),
("ESTJ", "ESTJ - Executive"), ("ESTJ", "ESTJ - Executive"),
@@ -241,17 +241,13 @@ class PlatformChatLink(models.Model):
raise ValidationError("Person must belong to the same user.") raise ValidationError("Person must belong to the same user.")
if self.person_identifier_id: if self.person_identifier_id:
if self.person_identifier.user_id != self.user_id: if self.person_identifier.user_id != self.user_id:
raise ValidationError( raise ValidationError("Person identifier must belong to the same user.")
"Person identifier must belong to the same user."
)
if self.person_identifier.person_id != self.person_id: if self.person_identifier.person_id != self.person_id:
raise ValidationError( raise ValidationError(
"Person identifier must belong to the selected person." "Person identifier must belong to the selected person."
) )
if self.person_identifier.service != self.service: if self.person_identifier.service != self.service:
raise ValidationError( raise ValidationError("Chat links cannot be linked across platforms.")
"Chat links cannot be linked across platforms."
)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
value = str(self.chat_identifier or "").strip() value = str(self.chat_identifier or "").strip()
@@ -1869,9 +1865,7 @@ class PatternArtifactExport(models.Model):
class CommandProfile(models.Model): class CommandProfile(models.Model):
WINDOW_SCOPE_CHOICES = ( WINDOW_SCOPE_CHOICES = (("conversation", "Conversation"),)
("conversation", "Conversation"),
)
VISIBILITY_CHOICES = ( VISIBILITY_CHOICES = (
("status_in_source", "Status In Source"), ("status_in_source", "Status In Source"),
("silent", "Silent"), ("silent", "Silent"),
@@ -2039,7 +2033,9 @@ class BusinessPlanDocument(models.Model):
class Meta: class Meta:
indexes = [ indexes = [
models.Index(fields=["user", "status", "updated_at"]), models.Index(fields=["user", "status", "updated_at"]),
models.Index(fields=["user", "source_service", "source_channel_identifier"]), models.Index(
fields=["user", "source_service", "source_channel_identifier"]
),
] ]
@@ -2243,7 +2239,9 @@ class TranslationEventLog(models.Model):
class AnswerMemory(models.Model): class AnswerMemory(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="answer_memory") user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="answer_memory"
)
service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES) service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
channel_identifier = models.CharField(max_length=255) channel_identifier = models.CharField(max_length=255)
question_fingerprint = models.CharField(max_length=128) question_fingerprint = models.CharField(max_length=128)
@@ -2261,7 +2259,9 @@ class AnswerMemory(models.Model):
class Meta: class Meta:
indexes = [ indexes = [
models.Index(fields=["user", "service", "channel_identifier", "created_at"]), models.Index(
fields=["user", "service", "channel_identifier", "created_at"]
),
models.Index(fields=["user", "question_fingerprint", "created_at"]), models.Index(fields=["user", "question_fingerprint", "created_at"]),
] ]
@@ -2284,7 +2284,9 @@ class AnswerSuggestionEvent(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="answer_suggestion_events", related_name="answer_suggestion_events",
) )
status = models.CharField(max_length=32, choices=STATUS_CHOICES, default="suggested") status = models.CharField(
max_length=32, choices=STATUS_CHOICES, default="suggested"
)
candidate_answer = models.ForeignKey( candidate_answer = models.ForeignKey(
AnswerMemory, AnswerMemory,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@@ -2305,7 +2307,9 @@ class AnswerSuggestionEvent(models.Model):
class TaskProject(models.Model): class TaskProject(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="task_projects") user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="task_projects"
)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
external_key = models.CharField(max_length=255, blank=True, default="") external_key = models.CharField(max_length=255, blank=True, default="")
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
@@ -2349,7 +2353,9 @@ class TaskEpic(models.Model):
class ChatTaskSource(models.Model): class ChatTaskSource(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="chat_task_sources") user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="chat_task_sources"
)
service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES) service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
channel_identifier = models.CharField(max_length=255) channel_identifier = models.CharField(max_length=255)
project = models.ForeignKey( project = models.ForeignKey(
@@ -2378,7 +2384,9 @@ class ChatTaskSource(models.Model):
class DerivedTask(models.Model): class DerivedTask(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="derived_tasks") user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="derived_tasks"
)
project = models.ForeignKey( project = models.ForeignKey(
TaskProject, TaskProject,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@@ -2574,7 +2582,9 @@ class ExternalSyncEvent(models.Model):
) )
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="external_sync_events") user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="external_sync_events"
)
task = models.ForeignKey( task = models.ForeignKey(
DerivedTask, DerivedTask,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@@ -2606,7 +2616,9 @@ class ExternalSyncEvent(models.Model):
class TaskProviderConfig(models.Model): class TaskProviderConfig(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="task_provider_configs") user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="task_provider_configs"
)
provider = models.CharField(max_length=64, default="mock") provider = models.CharField(max_length=64, default="mock")
enabled = models.BooleanField(default=False) enabled = models.BooleanField(default=False)
settings = models.JSONField(default=dict, blank=True) settings = models.JSONField(default=dict, blank=True)
@@ -2684,7 +2696,9 @@ class CodexRun(models.Model):
class Meta: class Meta:
indexes = [ indexes = [
models.Index(fields=["user", "status", "updated_at"]), models.Index(fields=["user", "status", "updated_at"]),
models.Index(fields=["user", "source_service", "source_channel", "created_at"]), models.Index(
fields=["user", "source_service", "source_channel", "created_at"]
),
] ]
@@ -2697,7 +2711,9 @@ class CodexPermissionRequest(models.Model):
) )
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="codex_permission_requests") user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="codex_permission_requests"
)
codex_run = models.ForeignKey( codex_run = models.ForeignKey(
CodexRun, CodexRun,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@@ -2910,7 +2926,49 @@ class UserXmppOmemoState(models.Model):
class Meta: class Meta:
indexes = [ indexes = [
models.Index(fields=["status", "updated_at"], name="core_userxm_status_133ead_idx"), models.Index(
fields=["status", "updated_at"], name="core_userxm_status_133ead_idx"
),
]
class UserXmppOmemoTrustedKey(models.Model):
KEY_TYPE_CHOICES = (
("fingerprint", "Fingerprint"),
("client_key", "Client key"),
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="xmpp_omemo_trusted_keys",
)
jid = models.CharField(max_length=255, blank=True, default="")
key_type = models.CharField(
max_length=32, choices=KEY_TYPE_CHOICES, default="fingerprint"
)
key_id = models.CharField(max_length=255)
trusted = models.BooleanField(default=False)
source = models.CharField(max_length=64, blank=True, default="")
last_seen_at = models.DateTimeField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["user", "jid", "key_type", "key_id"],
name="unique_user_xmpp_omemo_trusted_key",
),
]
indexes = [
models.Index(
fields=["user", "trusted", "updated_at"],
name="core_userxomemo_trusted_idx",
),
models.Index(
fields=["user", "jid", "updated_at"], name="core_userxomemo_jid_idx"
),
] ]
@@ -2921,6 +2979,7 @@ class UserXmppSecuritySettings(models.Model):
related_name="xmpp_security_settings", related_name="xmpp_security_settings",
) )
require_omemo = models.BooleanField(default=False) require_omemo = models.BooleanField(default=False)
encrypt_contact_messages_with_omemo = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -2938,7 +2997,9 @@ class UserAccessibilitySettings(models.Model):
class TaskCompletionPattern(models.Model): class TaskCompletionPattern(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="task_completion_patterns") user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="task_completion_patterns"
)
phrase = models.CharField(max_length=64) phrase = models.CharField(max_length=64)
enabled = models.BooleanField(default=True) enabled = models.BooleanField(default=True)
position = models.PositiveIntegerField(default=0) position = models.PositiveIntegerField(default=0)

View File

@@ -4,22 +4,22 @@ import re
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 core.assist.engine import process_inbound_assist
from core.clients import transport from core.clients import transport
from core.events import event_ledger_status
from core.clients.instagram import InstagramClient from core.clients.instagram import InstagramClient
from core.clients.signal import SignalClient from core.clients.signal import SignalClient
from core.clients.whatsapp import WhatsAppClient from core.clients.whatsapp import WhatsAppClient
from core.clients.xmpp import XMPPClient from core.clients.xmpp import XMPPClient
from core.assist.engine import process_inbound_assist
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.events import event_ledger_status
from core.messaging import history from core.messaging import history
from core.models import PersonIdentifier from core.models import PersonIdentifier
from core.observability.tracing import ensure_trace_id
from core.presence import AvailabilitySignal, record_native_signal from core.presence import AvailabilitySignal, record_native_signal
from core.realtime.typing_state import set_person_typing_state from core.realtime.typing_state import set_person_typing_state
from core.translation.engine import process_inbound_translation from core.translation.engine import process_inbound_translation
from core.util import logs from core.util import logs
from core.observability.tracing import ensure_trace_id
class UnifiedRouter(object): class UnifiedRouter(object):
@@ -119,7 +119,9 @@ class UnifiedRouter(object):
return return
identifiers = await self._resolve_identifier_objects(protocol, identifier) identifiers = await self._resolve_identifier_objects(protocol, identifier)
if identifiers: if identifiers:
outgoing = str(getattr(local_message, "custom_author", "") or "").strip().upper() in { outgoing = str(
getattr(local_message, "custom_author", "") or ""
).strip().upper() in {
"USER", "USER",
"BOT", "BOT",
} }
@@ -268,7 +270,9 @@ class UnifiedRouter(object):
ts=int(read_ts or 0), ts=int(read_ts or 0),
payload={ payload={
"origin": "router.message_read", "origin": "router.message_read",
"message_timestamps": [int(v) for v in list(timestamps or []) if str(v).isdigit()], "message_timestamps": [
int(v) for v in list(timestamps or []) if str(v).isdigit()
],
"read_by": str(read_by or row.identifier), "read_by": str(read_by or row.identifier),
}, },
) )

View File

@@ -12,9 +12,15 @@ from core.models import (
PersonIdentifier, PersonIdentifier,
User, User,
) )
from .inference import fade_confidence, now_ms, should_fade from .inference import fade_confidence, now_ms, should_fade
POSITIVE_SOURCE_KINDS = {"native_presence", "read_receipt", "typing_start", "message_in"} POSITIVE_SOURCE_KINDS = {
"native_presence",
"read_receipt",
"typing_start",
"message_in",
}
@dataclass(slots=True) @dataclass(slots=True)
@@ -99,7 +105,8 @@ def record_native_signal(signal: AvailabilitySignal) -> ContactAvailabilityEvent
person_identifier=signal.person_identifier, person_identifier=signal.person_identifier,
service=str(signal.service or "").strip().lower() or "signal", service=str(signal.service or "").strip().lower() or "signal",
source_kind=str(signal.source_kind or "").strip() or "native_presence", source_kind=str(signal.source_kind or "").strip() or "native_presence",
availability_state=str(signal.availability_state or "unknown").strip() or "unknown", availability_state=str(signal.availability_state or "unknown").strip()
or "unknown",
confidence=float(signal.confidence or 0.0), confidence=float(signal.confidence or 0.0),
ts=_normalize_ts(signal.ts), ts=_normalize_ts(signal.ts),
payload=dict(signal.payload or {}), payload=dict(signal.payload or {}),
@@ -109,7 +116,9 @@ def record_native_signal(signal: AvailabilitySignal) -> ContactAvailabilityEvent
return event return event
def record_inferred_signal(signal: AvailabilitySignal) -> ContactAvailabilityEvent | None: def record_inferred_signal(
signal: AvailabilitySignal,
) -> ContactAvailabilityEvent | None:
settings_row = get_settings(signal.user) settings_row = get_settings(signal.user)
if not settings_row.enabled or not settings_row.inference_enabled: if not settings_row.enabled or not settings_row.inference_enabled:
return None return None
@@ -151,7 +160,9 @@ def ensure_fading_state(
return None return None
if latest.source_kind not in POSITIVE_SOURCE_KINDS: if latest.source_kind not in POSITIVE_SOURCE_KINDS:
return None return None
if not should_fade(int(latest.ts or 0), current_ts, settings_row.fade_threshold_seconds): if not should_fade(
int(latest.ts or 0), current_ts, settings_row.fade_threshold_seconds
):
return None return None
elapsed = max(0, current_ts - int(latest.ts or 0)) elapsed = max(0, current_ts - int(latest.ts or 0))

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from django.db.models import Q from django.db.models import Q
from core.models import ContactAvailabilityEvent, ContactAvailabilitySpan, Person, User from core.models import ContactAvailabilityEvent, ContactAvailabilitySpan, Person, User
from .engine import ensure_fading_state from .engine import ensure_fading_state
from .inference import now_ms from .inference import now_ms
@@ -19,9 +20,7 @@ def spans_for_range(
qs = ContactAvailabilitySpan.objects.filter( qs = ContactAvailabilitySpan.objects.filter(
user=user, user=user,
person=person, person=person,
).filter( ).filter(Q(start_ts__lte=end_ts) & Q(end_ts__gte=start_ts))
Q(start_ts__lte=end_ts) & Q(end_ts__gte=start_ts)
)
if service: if service:
qs = qs.filter(service=str(service).strip().lower()) qs = qs.filter(service=str(service).strip().lower())

View File

@@ -1,2 +1 @@
"""Security helpers shared across transport adapters.""" """Security helpers shared across transport adapters."""

View File

@@ -101,7 +101,9 @@ def validate_attachment_metadata(
raise ValueError(f"blocked_mime_type:{normalized_type}") raise ValueError(f"blocked_mime_type:{normalized_type}")
allow_unmatched = bool(getattr(settings, "ATTACHMENT_ALLOW_UNKNOWN_MIME", False)) allow_unmatched = bool(getattr(settings, "ATTACHMENT_ALLOW_UNKNOWN_MIME", False))
if not any(fnmatch(normalized_type, pattern) for pattern in _allowed_mime_patterns()): if not any(
fnmatch(normalized_type, pattern) for pattern in _allowed_mime_patterns()
):
if not allow_unmatched: if not allow_unmatched:
raise ValueError(f"unsupported_mime_type:{normalized_type}") raise ValueError(f"unsupported_mime_type:{normalized_type}")

View File

@@ -68,15 +68,13 @@ def _omemo_facts(ctx: CommandSecurityContext) -> tuple[str, str]:
message_meta = dict(ctx.message_meta or {}) message_meta = dict(ctx.message_meta or {})
payload = dict(ctx.payload or {}) payload = dict(ctx.payload or {})
xmpp_meta = dict(message_meta.get("xmpp") or {}) xmpp_meta = dict(message_meta.get("xmpp") or {})
status = str( status = (
xmpp_meta.get("omemo_status") str(xmpp_meta.get("omemo_status") or payload.get("omemo_status") or "")
or payload.get("omemo_status") .strip()
or "" .lower()
).strip().lower() )
client_key = str( client_key = str(
xmpp_meta.get("omemo_client_key") xmpp_meta.get("omemo_client_key") or payload.get("omemo_client_key") or ""
or payload.get("omemo_client_key")
or ""
).strip() ).strip()
return status, client_key return status, client_key
@@ -160,7 +158,8 @@ def evaluate_command_policy(
service = _normalize_service(context.service) service = _normalize_service(context.service)
channel = _normalize_channel(context.channel_identifier) channel = _normalize_channel(context.channel_identifier)
allowed_services = [ allowed_services = [
item.lower() for item in _normalize_list(getattr(policy, "allowed_services", [])) item.lower()
for item in _normalize_list(getattr(policy, "allowed_services", []))
] ]
global_allowed_services = [ global_allowed_services = [
item.lower() item.lower()

View File

@@ -83,7 +83,9 @@ def ensure_default_source_for_chat(
message=None, message=None,
): ):
service_key = str(service or "").strip().lower() service_key = str(service or "").strip().lower()
normalized_identifier = normalize_channel_identifier(service_key, channel_identifier) normalized_identifier = normalize_channel_identifier(
service_key, channel_identifier
)
variants = channel_variants(service_key, normalized_identifier) variants = channel_variants(service_key, normalized_identifier)
if not service_key or not variants: if not service_key or not variants:
return None return None

View File

@@ -72,9 +72,13 @@ def queue_codex_event_with_pre_approval(
}, },
) )
cfg = TaskProviderConfig.objects.filter(user=user, provider=provider, enabled=True).first() cfg = TaskProviderConfig.objects.filter(
user=user, provider=provider, enabled=True
).first()
settings_payload = dict(getattr(cfg, "settings", {}) or {}) settings_payload = dict(getattr(cfg, "settings", {}) or {})
approver_service = str(settings_payload.get("approver_service") or "").strip().lower() approver_service = (
str(settings_payload.get("approver_service") or "").strip().lower()
)
approver_identifier = str(settings_payload.get("approver_identifier") or "").strip() approver_identifier = str(settings_payload.get("approver_identifier") or "").strip()
if approver_service and approver_identifier: if approver_service and approver_identifier:
try: try:

View File

@@ -57,7 +57,9 @@ def resolve_external_chat_id(*, user, provider: str, service: str, channel: str)
provider=provider, provider=provider,
enabled=True, enabled=True,
) )
.filter(Q(person_identifier=person_identifier) | Q(person=person_identifier.person)) .filter(
Q(person_identifier=person_identifier) | Q(person=person_identifier.person)
)
.order_by("-updated_at", "-id") .order_by("-updated_at", "-id")
.first() .first()
) )

View File

@@ -22,16 +22,23 @@ from core.models import (
TaskEpic, TaskEpic,
TaskProviderConfig, TaskProviderConfig,
) )
from core.tasks.chat_defaults import ensure_default_source_for_chat, resolve_message_scope
from core.tasks.codex_approval import queue_codex_event_with_pre_approval
from core.tasks.providers import get_provider
from core.tasks.codex_support import resolve_external_chat_id
from core.security.command_policy import CommandSecurityContext, evaluate_command_policy from core.security.command_policy import CommandSecurityContext, evaluate_command_policy
from core.tasks.chat_defaults import (
ensure_default_source_for_chat,
resolve_message_scope,
)
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.providers import get_provider
_TASK_HINT_RE = re.compile(r"\b(todo|task|action|need to|please)\b", re.IGNORECASE) _TASK_HINT_RE = re.compile(r"\b(todo|task|action|need to|please)\b", re.IGNORECASE)
_COMPLETION_RE = re.compile(r"\b(done|completed|fixed)\s*#([A-Za-z0-9_-]+)\b", re.IGNORECASE) _COMPLETION_RE = re.compile(
r"\b(done|completed|fixed)\s*#([A-Za-z0-9_-]+)\b", re.IGNORECASE
)
_BALANCED_HINT_RE = re.compile(r"\b(todo|task|action item|action)\b", re.IGNORECASE) _BALANCED_HINT_RE = re.compile(r"\b(todo|task|action item|action)\b", re.IGNORECASE)
_BROAD_HINT_RE = re.compile(r"\b(todo|task|action|need to|please|reminder)\b", re.IGNORECASE) _BROAD_HINT_RE = re.compile(
r"\b(todo|task|action|need to|please|reminder)\b", re.IGNORECASE
)
_PREFIX_HEAD_TRIM = " \t\r\n`'\"([{<*#-—_>.,:;!/?\\|" _PREFIX_HEAD_TRIM = " \t\r\n`'\"([{<*#-—_>.,:;!/?\\|"
_LIST_TASKS_RE = re.compile( _LIST_TASKS_RE = re.compile(
r"^\s*(?:\.l(?:\s+list(?:\s+tasks?)?)?|\.list(?:\s+tasks?)?)\s*$", r"^\s*(?:\.l(?:\s+list(?:\s+tasks?)?)?|\.list(?:\s+tasks?)?)\s*$",
@@ -151,15 +158,23 @@ async def _resolve_source_mappings(message: Message) -> list[ChatTaskSource]:
lookup_service = str(message.source_service or "").strip().lower() lookup_service = str(message.source_service or "").strip().lower()
variants = _channel_variants(lookup_service, message.source_chat_id or "") variants = _channel_variants(lookup_service, message.source_chat_id or "")
session_identifier = getattr(getattr(message, "session", None), "identifier", None) session_identifier = getattr(getattr(message, "session", None), "identifier", None)
canonical_service = str(getattr(session_identifier, "service", "") or "").strip().lower() canonical_service = (
canonical_identifier = str(getattr(session_identifier, "identifier", "") or "").strip() str(getattr(session_identifier, "service", "") or "").strip().lower()
)
canonical_identifier = str(
getattr(session_identifier, "identifier", "") or ""
).strip()
if lookup_service == "web" and canonical_service and canonical_service != "web": if lookup_service == "web" and canonical_service and canonical_service != "web":
lookup_service = canonical_service lookup_service = canonical_service
variants = _channel_variants(lookup_service, message.source_chat_id or "") variants = _channel_variants(lookup_service, message.source_chat_id or "")
for expanded in _channel_variants(lookup_service, canonical_identifier): for expanded in _channel_variants(lookup_service, canonical_identifier):
if expanded and expanded not in variants: if expanded and expanded not in variants:
variants.append(expanded) variants.append(expanded)
elif canonical_service and canonical_identifier and canonical_service == lookup_service: elif (
canonical_service
and canonical_identifier
and canonical_service == lookup_service
):
for expanded in _channel_variants(canonical_service, canonical_identifier): for expanded in _channel_variants(canonical_service, canonical_identifier):
if expanded and expanded not in variants: if expanded and expanded not in variants:
variants.append(expanded) variants.append(expanded)
@@ -170,10 +185,14 @@ async def _resolve_source_mappings(message: Message) -> list[ChatTaskSource]:
if not signal_value: if not signal_value:
continue continue
companions += await sync_to_async(list)( companions += await sync_to_async(list)(
Chat.objects.filter(source_uuid=signal_value).values_list("source_number", flat=True) Chat.objects.filter(source_uuid=signal_value).values_list(
"source_number", flat=True
)
) )
companions += await sync_to_async(list)( companions += await sync_to_async(list)(
Chat.objects.filter(source_number=signal_value).values_list("source_uuid", flat=True) Chat.objects.filter(source_number=signal_value).values_list(
"source_uuid", flat=True
)
) )
for candidate in companions: for candidate in companions:
for expanded in _channel_variants("signal", str(candidate or "").strip()): for expanded in _channel_variants("signal", str(candidate or "").strip()):
@@ -271,7 +290,8 @@ def _normalize_flags(raw: dict | None) -> dict:
row = dict(raw or {}) row = dict(raw or {})
return { return {
"derive_enabled": _to_bool(row.get("derive_enabled"), True), "derive_enabled": _to_bool(row.get("derive_enabled"), True),
"match_mode": str(row.get("match_mode") or "balanced").strip().lower() or "balanced", "match_mode": str(row.get("match_mode") or "balanced").strip().lower()
or "balanced",
"require_prefix": _to_bool(row.get("require_prefix"), False), "require_prefix": _to_bool(row.get("require_prefix"), False),
"allowed_prefixes": _parse_prefixes(row.get("allowed_prefixes")), "allowed_prefixes": _parse_prefixes(row.get("allowed_prefixes")),
"completion_enabled": _to_bool(row.get("completion_enabled"), True), "completion_enabled": _to_bool(row.get("completion_enabled"), True),
@@ -287,7 +307,9 @@ def _normalize_partial_flags(raw: dict | None) -> dict:
if "derive_enabled" in row: if "derive_enabled" in row:
out["derive_enabled"] = _to_bool(row.get("derive_enabled"), True) out["derive_enabled"] = _to_bool(row.get("derive_enabled"), True)
if "match_mode" in row: if "match_mode" in row:
out["match_mode"] = str(row.get("match_mode") or "balanced").strip().lower() or "balanced" out["match_mode"] = (
str(row.get("match_mode") or "balanced").strip().lower() or "balanced"
)
if "require_prefix" in row: if "require_prefix" in row:
out["require_prefix"] = _to_bool(row.get("require_prefix"), False) out["require_prefix"] = _to_bool(row.get("require_prefix"), False)
if "allowed_prefixes" in row: if "allowed_prefixes" in row:
@@ -304,7 +326,9 @@ def _normalize_partial_flags(raw: dict | None) -> dict:
def _effective_flags(source: ChatTaskSource) -> dict: def _effective_flags(source: ChatTaskSource) -> dict:
project_flags = _normalize_flags(getattr(getattr(source, "project", None), "settings", {}) or {}) project_flags = _normalize_flags(
getattr(getattr(source, "project", None), "settings", {}) or {}
)
source_flags = _normalize_partial_flags(getattr(source, "settings", {}) or {}) source_flags = _normalize_partial_flags(getattr(source, "settings", {}) or {})
merged = dict(project_flags) merged = dict(project_flags)
merged.update(source_flags) merged.update(source_flags)
@@ -360,7 +384,10 @@ async def _derive_title(message: Message) -> str:
{"role": "user", "content": text[:2000]}, {"role": "user", "content": text[:2000]},
] ]
try: try:
title = str(await ai_runner.run_prompt(prompt, ai_obj, operation="task_derive_title") or "").strip() title = str(
await ai_runner.run_prompt(prompt, ai_obj, operation="task_derive_title")
or ""
).strip()
except Exception: except Exception:
title = "" title = ""
return (title or text)[:255] return (title or text)[:255]
@@ -376,9 +403,13 @@ async def _derive_title_with_flags(message: Message, flags: dict) -> str:
return (cleaned or title or "Untitled task")[:255] return (cleaned or title or "Untitled task")[:255]
async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: str) -> None: async def _emit_sync_event(
task: DerivedTask, event: DerivedTaskEvent, action: str
) -> None:
cfg = await sync_to_async( cfg = await sync_to_async(
lambda: TaskProviderConfig.objects.filter(user=task.user, enabled=True).order_by("provider").first() lambda: TaskProviderConfig.objects.filter(user=task.user, enabled=True)
.order_by("provider")
.first()
)() )()
provider_name = str(getattr(cfg, "provider", "mock") or "mock") provider_name = str(getattr(cfg, "provider", "mock") or "mock")
provider_settings = dict(getattr(cfg, "settings", {}) or {}) provider_settings = dict(getattr(cfg, "settings", {}) or {})
@@ -416,7 +447,11 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
"source_channel": str(task.source_channel or ""), "source_channel": str(task.source_channel or ""),
"external_chat_id": external_chat_id, "external_chat_id": external_chat_id,
"origin_message_id": str(getattr(task, "origin_message_id", "") or ""), "origin_message_id": str(getattr(task, "origin_message_id", "") or ""),
"trigger_message_id": str(getattr(event, "source_message_id", "") or getattr(task, "origin_message_id", "") or ""), "trigger_message_id": str(
getattr(event, "source_message_id", "")
or getattr(task, "origin_message_id", "")
or ""
),
"mode": "default", "mode": "default",
"payload": event.payload, "payload": event.payload,
"memory_context": memory_context, "memory_context": memory_context,
@@ -495,7 +530,9 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
codex_run.status = status codex_run.status = status
codex_run.result_payload = dict(result.payload or {}) codex_run.result_payload = dict(result.payload or {})
codex_run.error = str(result.error or "") codex_run.error = str(result.error or "")
await sync_to_async(codex_run.save)(update_fields=["status", "result_payload", "error", "updated_at"]) await sync_to_async(codex_run.save)(
update_fields=["status", "result_payload", "error", "updated_at"]
)
if result.ok and result.external_key and not task.external_key: if result.ok and result.external_key and not task.external_key:
task.external_key = str(result.external_key) task.external_key = str(result.external_key)
await sync_to_async(task.save)(update_fields=["external_key"]) await sync_to_async(task.save)(update_fields=["external_key"])
@@ -503,15 +540,28 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
async def _completion_regex(message: Message) -> re.Pattern: async def _completion_regex(message: Message) -> re.Pattern:
patterns = await sync_to_async(list)( patterns = await sync_to_async(list)(
TaskCompletionPattern.objects.filter(user=message.user, enabled=True).order_by("position", "created_at") TaskCompletionPattern.objects.filter(user=message.user, enabled=True).order_by(
"position", "created_at"
)
) )
phrases = [str(row.phrase or "").strip() for row in patterns if str(row.phrase or "").strip()] phrases = [
str(row.phrase or "").strip()
for row in patterns
if str(row.phrase or "").strip()
]
if not phrases: if not phrases:
phrases = ["done", "completed", "fixed"] phrases = ["done", "completed", "fixed"]
return re.compile(r"\\b(?:" + "|".join(re.escape(p) for p in phrases) + r")\\s*#([A-Za-z0-9_-]+)\\b", re.IGNORECASE) return re.compile(
r"\\b(?:"
+ "|".join(re.escape(p) for p in phrases)
+ r")\\s*#([A-Za-z0-9_-]+)\\b",
re.IGNORECASE,
)
async def _send_scope_message(source: ChatTaskSource, message: Message, text: str) -> None: async def _send_scope_message(
source: ChatTaskSource, message: Message, text: str
) -> None:
await send_message_raw( await send_message_raw(
source.service or message.source_service or "web", source.service or message.source_service or "web",
source.channel_identifier or message.source_chat_id or "", source.channel_identifier or message.source_chat_id or "",
@@ -521,7 +571,9 @@ async def _send_scope_message(source: ChatTaskSource, message: Message, text: st
) )
async def _handle_scope_task_commands(message: Message, sources: list[ChatTaskSource], text: str) -> bool: async def _handle_scope_task_commands(
message: Message, sources: list[ChatTaskSource], text: str
) -> bool:
if not sources: if not sources:
return False return False
body = str(text or "").strip() body = str(text or "").strip()
@@ -538,7 +590,9 @@ async def _handle_scope_task_commands(message: Message, sources: list[ChatTaskSo
.order_by("-created_at")[:20] .order_by("-created_at")[:20]
) )
if not open_rows: if not open_rows:
await _send_scope_message(source, message, "[task] no open tasks in this chat.") await _send_scope_message(
source, message, "[task] no open tasks in this chat."
)
return True return True
lines = ["[task] open tasks:"] lines = ["[task] open tasks:"]
for row in open_rows: for row in open_rows:
@@ -573,7 +627,9 @@ async def _handle_scope_task_commands(message: Message, sources: list[ChatTaskSo
.first() .first()
)() )()
if task is None: if task is None:
await _send_scope_message(source, message, "[task] nothing to undo in this chat.") await _send_scope_message(
source, message, "[task] nothing to undo in this chat."
)
return True return True
ref = str(task.reference_code or "") ref = str(task.reference_code or "")
title = str(task.title or "") title = str(task.title or "")
@@ -596,10 +652,16 @@ async def _handle_scope_task_commands(message: Message, sources: list[ChatTaskSo
.first() .first()
)() )()
if task is None: if task is None:
await _send_scope_message(source, message, f"[task] #{reference} not found.") await _send_scope_message(
source, message, f"[task] #{reference} not found."
)
return True return True
due_str = f"\ndue: {task.due_date}" if task.due_date else "" due_str = f"\ndue: {task.due_date}" if task.due_date else ""
assignee_str = f"\nassignee: {task.assignee_identifier}" if task.assignee_identifier else "" assignee_str = (
f"\nassignee: {task.assignee_identifier}"
if task.assignee_identifier
else ""
)
detail = ( detail = (
f"[task] #{task.reference_code}: {task.title}" f"[task] #{task.reference_code}: {task.title}"
f"\nstatus: {task.status_snapshot}" f"\nstatus: {task.status_snapshot}"
@@ -624,7 +686,9 @@ async def _handle_scope_task_commands(message: Message, sources: list[ChatTaskSo
.first() .first()
)() )()
if task is None: if task is None:
await _send_scope_message(source, message, f"[task] #{reference} not found.") await _send_scope_message(
source, message, f"[task] #{reference} not found."
)
return True return True
task.status_snapshot = "completed" task.status_snapshot = "completed"
await sync_to_async(task.save)(update_fields=["status_snapshot"]) await sync_to_async(task.save)(update_fields=["status_snapshot"])
@@ -633,10 +697,16 @@ async def _handle_scope_task_commands(message: Message, sources: list[ChatTaskSo
event_type="completion_marked", event_type="completion_marked",
actor_identifier=str(message.sender_uuid or ""), actor_identifier=str(message.sender_uuid or ""),
source_message=message, source_message=message,
payload={"marker": reference, "command": ".task complete", "via": "chat_command"}, payload={
"marker": reference,
"command": ".task complete",
"via": "chat_command",
},
) )
await _emit_sync_event(task, event, "complete") await _emit_sync_event(task, event, "complete")
await _send_scope_message(source, message, f"[task] completed #{task.reference_code}: {task.title}") await _send_scope_message(
source, message, f"[task] completed #{task.reference_code}: {task.title}"
)
return True return True
return False return False
@@ -656,7 +726,9 @@ def _strip_epic_token(text: str) -> str:
return re.sub(r"\s{2,}", " ", cleaned).strip() return re.sub(r"\s{2,}", " ", cleaned).strip()
async def _handle_epic_create_command(message: Message, sources: list[ChatTaskSource], text: str) -> bool: async def _handle_epic_create_command(
message: Message, sources: list[ChatTaskSource], text: str
) -> bool:
match = _EPIC_CREATE_RE.match(str(text or "")) match = _EPIC_CREATE_RE.match(str(text or ""))
if not match or not sources: if not match or not sources:
return False return False
@@ -766,13 +838,21 @@ async def process_inbound_task_intelligence(message: Message) -> None:
if not submit_decision.allowed: if not submit_decision.allowed:
return return
completion_allowed = any(bool(_effective_flags(source).get("completion_enabled")) for source in sources) completion_allowed = any(
bool(_effective_flags(source).get("completion_enabled")) for source in sources
)
completion_rx = await _completion_regex(message) if completion_allowed else None completion_rx = await _completion_regex(message) if completion_allowed else None
marker_match = (completion_rx.search(text) if completion_rx else None) or (_COMPLETION_RE.search(text) if completion_allowed else None) marker_match = (completion_rx.search(text) if completion_rx else None) or (
_COMPLETION_RE.search(text) if completion_allowed else None
)
if marker_match: if marker_match:
ref_code = str(marker_match.group(marker_match.lastindex or 1) or "").strip() ref_code = str(marker_match.group(marker_match.lastindex or 1) or "").strip()
task = await sync_to_async( task = await sync_to_async(
lambda: DerivedTask.objects.filter(user=message.user, reference_code=ref_code).order_by("-created_at").first() lambda: DerivedTask.objects.filter(
user=message.user, reference_code=ref_code
)
.order_by("-created_at")
.first()
)() )()
if not task: if not task:
# parser warning event attached to a newly derived placeholder in mapped project # parser warning event attached to a newly derived placeholder in mapped project
@@ -848,7 +928,11 @@ async def process_inbound_task_intelligence(message: Message) -> None:
status_snapshot="open", status_snapshot="open",
due_date=parsed_due_date, due_date=parsed_due_date,
assignee_identifier=parsed_assignee, assignee_identifier=parsed_assignee,
immutable_payload={"origin_text": text, "task_text": task_text, "flags": flags}, immutable_payload={
"origin_text": text,
"task_text": task_text,
"flags": flags,
},
) )
event = await sync_to_async(DerivedTaskEvent.objects.create)( event = await sync_to_async(DerivedTaskEvent.objects.create)(
task=task, task=task,

View File

@@ -40,13 +40,21 @@ class ClaudeCLITaskProvider(TaskProvider):
return True return True
if "unrecognized subcommand 'create'" in text and "usage: claude" in text: if "unrecognized subcommand 'create'" in text and "usage: claude" in text:
return True return True
if "unrecognized subcommand 'append_update'" in text and "usage: claude" in text: if (
"unrecognized subcommand 'append_update'" in text
and "usage: claude" in text
):
return True return True
if "unrecognized subcommand 'mark_complete'" in text and "usage: claude" in text: if (
"unrecognized subcommand 'mark_complete'" in text
and "usage: claude" in text
):
return True return True
return False return False
def _builtin_stub_result(self, op: str, payload: dict, stderr: str) -> ProviderResult: def _builtin_stub_result(
self, op: str, payload: dict, stderr: str
) -> ProviderResult:
mode = str(payload.get("mode") or "default").strip().lower() mode = str(payload.get("mode") or "default").strip().lower()
external_key = ( external_key = (
str(payload.get("external_key") or "").strip() str(payload.get("external_key") or "").strip()
@@ -117,7 +125,10 @@ class ClaudeCLITaskProvider(TaskProvider):
cwd=workspace if workspace else None, cwd=workspace if workspace else None,
) )
stderr_probe = str(completed.stderr or "").lower() stderr_probe = str(completed.stderr or "").lower()
if completed.returncode != 0 and "unexpected argument '--op'" in stderr_probe: if (
completed.returncode != 0
and "unexpected argument '--op'" in stderr_probe
):
completed = subprocess.run( completed = subprocess.run(
fallback_cmd, fallback_cmd,
capture_output=True, capture_output=True,
@@ -133,7 +144,9 @@ class ClaudeCLITaskProvider(TaskProvider):
payload={"op": op, "timeout_seconds": command_timeout}, payload={"op": op, "timeout_seconds": command_timeout},
) )
except Exception as exc: except Exception as exc:
return ProviderResult(ok=False, error=f"claude_cli_exec_error:{exc}", payload={"op": op}) return ProviderResult(
ok=False, error=f"claude_cli_exec_error:{exc}", payload={"op": op}
)
stdout = str(completed.stdout or "").strip() stdout = str(completed.stdout or "").strip()
stderr = str(completed.stderr or "").strip() stderr = str(completed.stderr or "").strip()
@@ -172,7 +185,12 @@ class ClaudeCLITaskProvider(TaskProvider):
out_payload.update(parsed) out_payload.update(parsed)
if (not ok) and self._is_task_sync_contract_mismatch(stderr): if (not ok) and self._is_task_sync_contract_mismatch(stderr):
return self._builtin_stub_result(op, dict(payload or {}), stderr) return self._builtin_stub_result(op, dict(payload or {}), stderr)
return ProviderResult(ok=ok, external_key=ext, error=("" if ok else stderr[:4000]), payload=out_payload) return ProviderResult(
ok=ok,
external_key=ext,
error=("" if ok else stderr[:4000]),
payload=out_payload,
)
def healthcheck(self, config: dict) -> ProviderResult: def healthcheck(self, config: dict) -> ProviderResult:
command = self._command(config) command = self._command(config)
@@ -193,7 +211,11 @@ class ClaudeCLITaskProvider(TaskProvider):
"stdout": str(completed.stdout or "").strip()[:1000], "stdout": str(completed.stdout or "").strip()[:1000],
"stderr": str(completed.stderr or "").strip()[:1000], "stderr": str(completed.stderr or "").strip()[:1000],
}, },
error=("" if completed.returncode == 0 else str(completed.stderr or "").strip()[:1000]), error=(
""
if completed.returncode == 0
else str(completed.stderr or "").strip()[:1000]
),
) )
def create_task(self, config: dict, payload: dict) -> ProviderResult: def create_task(self, config: dict, payload: dict) -> ProviderResult:

View File

@@ -46,7 +46,9 @@ class CodexCLITaskProvider(TaskProvider):
return True return True
return False return False
def _builtin_stub_result(self, op: str, payload: dict, stderr: str) -> ProviderResult: def _builtin_stub_result(
self, op: str, payload: dict, stderr: str
) -> ProviderResult:
mode = str(payload.get("mode") or "default").strip().lower() mode = str(payload.get("mode") or "default").strip().lower()
external_key = ( external_key = (
str(payload.get("external_key") or "").strip() str(payload.get("external_key") or "").strip()
@@ -117,7 +119,10 @@ class CodexCLITaskProvider(TaskProvider):
cwd=workspace if workspace else None, cwd=workspace if workspace else None,
) )
stderr_probe = str(completed.stderr or "").lower() stderr_probe = str(completed.stderr or "").lower()
if completed.returncode != 0 and "unexpected argument '--op'" in stderr_probe: if (
completed.returncode != 0
and "unexpected argument '--op'" in stderr_probe
):
completed = subprocess.run( completed = subprocess.run(
fallback_cmd, fallback_cmd,
capture_output=True, capture_output=True,
@@ -133,7 +138,9 @@ class CodexCLITaskProvider(TaskProvider):
payload={"op": op, "timeout_seconds": command_timeout}, payload={"op": op, "timeout_seconds": command_timeout},
) )
except Exception as exc: except Exception as exc:
return ProviderResult(ok=False, error=f"codex_cli_exec_error:{exc}", payload={"op": op}) return ProviderResult(
ok=False, error=f"codex_cli_exec_error:{exc}", payload={"op": op}
)
stdout = str(completed.stdout or "").strip() stdout = str(completed.stdout or "").strip()
stderr = str(completed.stderr or "").strip() stderr = str(completed.stderr or "").strip()
@@ -172,7 +179,12 @@ class CodexCLITaskProvider(TaskProvider):
out_payload.update(parsed) out_payload.update(parsed)
if (not ok) and self._is_task_sync_contract_mismatch(stderr): if (not ok) and self._is_task_sync_contract_mismatch(stderr):
return self._builtin_stub_result(op, dict(payload or {}), stderr) return self._builtin_stub_result(op, dict(payload or {}), stderr)
return ProviderResult(ok=ok, external_key=ext, error=("" if ok else stderr[:4000]), payload=out_payload) return ProviderResult(
ok=ok,
external_key=ext,
error=("" if ok else stderr[:4000]),
payload=out_payload,
)
def healthcheck(self, config: dict) -> ProviderResult: def healthcheck(self, config: dict) -> ProviderResult:
command = self._command(config) command = self._command(config)
@@ -193,7 +205,11 @@ class CodexCLITaskProvider(TaskProvider):
"stdout": str(completed.stdout or "").strip()[:1000], "stdout": str(completed.stdout or "").strip()[:1000],
"stderr": str(completed.stderr or "").strip()[:1000], "stderr": str(completed.stderr or "").strip()[:1000],
}, },
error=("" if completed.returncode == 0 else str(completed.stderr or "").strip()[:1000]), error=(
""
if completed.returncode == 0
else str(completed.stderr or "").strip()[:1000]
),
) )
def create_task(self, config: dict, payload: dict) -> ProviderResult: def create_task(self, config: dict, payload: dict) -> ProviderResult:

View File

@@ -12,14 +12,30 @@ class MockTaskProvider(TaskProvider):
return ProviderResult(ok=True, payload={"provider": self.name}) return ProviderResult(ok=True, payload={"provider": self.name})
def create_task(self, config: dict, payload: dict) -> ProviderResult: def create_task(self, config: dict, payload: dict) -> ProviderResult:
ext = str(payload.get("external_key") or "") or f"mock-{int(time.time() * 1000)}" ext = (
return ProviderResult(ok=True, external_key=ext, payload={"action": "create_task"}) str(payload.get("external_key") or "") or f"mock-{int(time.time() * 1000)}"
)
return ProviderResult(
ok=True, external_key=ext, payload={"action": "create_task"}
)
def append_update(self, config: dict, payload: dict) -> ProviderResult: def append_update(self, config: dict, payload: dict) -> ProviderResult:
return ProviderResult(ok=True, external_key=str(payload.get("external_key") or ""), payload={"action": "append_update"}) return ProviderResult(
ok=True,
external_key=str(payload.get("external_key") or ""),
payload={"action": "append_update"},
)
def mark_complete(self, config: dict, payload: dict) -> ProviderResult: def mark_complete(self, config: dict, payload: dict) -> ProviderResult:
return ProviderResult(ok=True, external_key=str(payload.get("external_key") or ""), payload={"action": "mark_complete"}) return ProviderResult(
ok=True,
external_key=str(payload.get("external_key") or ""),
payload={"action": "mark_complete"},
)
def link_task(self, config: dict, payload: dict) -> ProviderResult: def link_task(self, config: dict, payload: dict) -> ProviderResult:
return ProviderResult(ok=True, external_key=str(payload.get("external_key") or ""), payload={"action": "link_task"}) return ProviderResult(
ok=True,
external_key=str(payload.get("external_key") or ""),
payload={"action": "link_task"},
)

View File

@@ -342,7 +342,7 @@
hx-trigger="click" hx-trigger="click"
hx-swap="innerHTML"> hx-swap="innerHTML">
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span> <span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
<span style="margin-left: 0.35rem;">Message</span> <span style="margin-left: 0.35rem;">Compose</span>
</a> </a>
<div class="navbar-dropdown" id="nav-compose-contacts"> <div class="navbar-dropdown" id="nav-compose-contacts">
<a <a
@@ -350,55 +350,20 @@
hx-get="{% url 'compose_contacts_dropdown' %}?all=1" hx-get="{% url 'compose_contacts_dropdown' %}?all=1"
hx-target="#nav-compose-contacts" hx-target="#nav-compose-contacts"
hx-swap="innerHTML"> hx-swap="innerHTML">
Fetch Contacts Open Contacts
</a> </a>
</div> </div>
</div> </div>
<a class="navbar-item" href="{% url 'tasks_hub' %}"> <a class="navbar-item" href="{% url 'tasks_hub' %}">
Tasks Task Inbox
</a> </a>
<a class="navbar-item" href="{% url 'ai_workspace' %}"> <a class="navbar-item" href="{% url 'ai_workspace' %}">
AI AI
</a> </a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
Security
</a>
<div class="navbar-dropdown">
<a
class="navbar-item{% if request.resolver_match.url_name == 'encryption_settings' or request.resolver_match.url_name == 'security_settings' %} is-current-route{% endif %}"
href="{% url 'encryption_settings' %}"
>
Encryption
</a>
<a
class="navbar-item{% if request.resolver_match.url_name == 'permission_settings' %} is-current-route{% endif %}"
href="{% url 'permission_settings' %}"
>
Permission
</a>
<a
class="navbar-item{% if request.resolver_match.url_name == 'security_2fa' or request.resolver_match.namespace == 'two_factor' %} is-current-route{% endif %}"
href="{% url 'security_2fa' %}"
>
2FA
</a>
</div>
</div>
<a class="navbar-item" href="{% url 'osint_search' type='page' %}"> <a class="navbar-item" href="{% url 'osint_search' type='page' %}">
Search Search
</a> </a>
<a class="navbar-item" href="{% url 'queues' type='page' %}">
Queue
</a>
<a class="navbar-item" href="{% url 'osint_workspace' %}">
OSINT
</a>
{% endif %} {% endif %}
<a class="navbar-item add-button">
Install
</a>
</div> </div>
<div class="navbar-end"> <div class="navbar-end">
@@ -423,15 +388,15 @@
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link"> <a class="navbar-link">
Storage Data
</a> </a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="{% url 'sessions' type='page' %}"> <a class="navbar-item" href="{% url 'sessions' type='page' %}">
Sessions Sessions
</a> </a>
<a class="navbar-item" href="{% url 'command_routing' %}#bp-documents"> <a class="navbar-item{% if request.resolver_match.url_name == 'business_plan_inbox' or request.resolver_match.url_name == 'business_plan_editor' %} is-current-route{% endif %}" href="{% url 'business_plan_inbox' %}">
Documents Business Plans
</a> </a>
</div> </div>
</div> </div>
@@ -454,6 +419,19 @@
</a> </a>
{% endif %} {% endif %}
<hr class="navbar-divider"> <hr class="navbar-divider">
<div class="navbar-item has-text-weight-semibold is-size-7 has-text-grey">
Security
</div>
<a class="navbar-item{% if request.resolver_match.url_name == 'encryption_settings' or request.resolver_match.url_name == 'security_settings' %} is-current-route{% endif %}" href="{% url 'encryption_settings' %}">
Encryption
</a>
<a class="navbar-item{% if request.resolver_match.url_name == 'permission_settings' %} is-current-route{% endif %}" href="{% url 'permission_settings' %}">
Permissions
</a>
<a class="navbar-item{% if request.resolver_match.url_name == 'security_2fa' or request.resolver_match.namespace == 'two_factor' %} is-current-route{% endif %}" href="{% url 'security_2fa' %}">
2FA
</a>
<hr class="navbar-divider">
<div class="navbar-item has-text-weight-semibold is-size-7 has-text-grey"> <div class="navbar-item has-text-weight-semibold is-size-7 has-text-grey">
AI AI
</div> </div>
@@ -470,8 +448,11 @@
<a class="navbar-item{% if request.resolver_match.url_name == 'command_routing' %} is-current-route{% endif %}" href="{% url 'command_routing' %}"> <a class="navbar-item{% if request.resolver_match.url_name == 'command_routing' %} is-current-route{% endif %}" href="{% url 'command_routing' %}">
Commands Commands
</a> </a>
<a class="navbar-item{% if request.resolver_match.url_name == 'business_plan_inbox' or request.resolver_match.url_name == 'business_plan_editor' %} is-current-route{% endif %}" href="{% url 'business_plan_inbox' %}">
Business Plans
</a>
<a class="navbar-item{% if request.resolver_match.url_name == 'tasks_settings' %} is-current-route{% endif %}" href="{% url 'tasks_settings' %}"> <a class="navbar-item{% if request.resolver_match.url_name == 'tasks_settings' %} is-current-route{% endif %}" href="{% url 'tasks_settings' %}">
Tasks Task Automation
</a> </a>
<a class="navbar-item{% if request.resolver_match.url_name == 'translation_settings' %} is-current-route{% endif %}" href="{% url 'translation_settings' %}"> <a class="navbar-item{% if request.resolver_match.url_name == 'translation_settings' %} is-current-route{% endif %}" href="{% url 'translation_settings' %}">
Translation Translation
@@ -480,6 +461,16 @@
Availability Availability
</a> </a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<div class="navbar-item has-text-weight-semibold is-size-7 has-text-grey">
Automation
</div>
<a class="navbar-item{% if request.resolver_match.url_name == 'queues' %} is-current-route{% endif %}" href="{% url 'queues' type='page' %}">
Approvals Queue
</a>
<a class="navbar-item{% if request.resolver_match.url_name == 'osint_workspace' %} is-current-route{% endif %}" href="{% url 'osint_workspace' %}">
OSINT Workspace
</a>
<hr class="navbar-divider">
<a class="navbar-item{% if request.resolver_match.url_name == 'accessibility_settings' %} is-current-route{% endif %}" href="{% url 'accessibility_settings' %}"> <a class="navbar-item{% if request.resolver_match.url_name == 'accessibility_settings' %} is-current-route{% endif %}" href="{% url 'accessibility_settings' %}">
Accessibility Accessibility
</a> </a>
@@ -499,6 +490,7 @@
{% endif %} {% endif %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
<button class="button is-light add-button" type="button" style="display:none;">Install App</button>
<a class="button is-dark" href="{% url 'logout' %}">Logout</a> <a class="button is-dark" href="{% url 'logout' %}">Logout</a>
{% endif %} {% endif %}
@@ -510,8 +502,13 @@
<script> <script>
let deferredPrompt; let deferredPrompt;
const addBtn = document.querySelector('.add-button'); const addBtn = document.querySelector('.add-button');
addBtn.style.display = 'none'; if (addBtn) {
addBtn.style.display = 'none';
}
window.addEventListener('beforeinstallprompt', (e) => { window.addEventListener('beforeinstallprompt', (e) => {
if (!addBtn) {
return;
}
// Prevent Chrome 67 and earlier from automatically showing the prompt // Prevent Chrome 67 and earlier from automatically showing the prompt
e.preventDefault(); e.preventDefault();
// Stash the event so it can be triggered later. // Stash the event so it can be triggered later.

View File

@@ -1,25 +1,25 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<h1 class="title is-4">Accessibility</h1> <h1 class="title is-4">Accessibility</h1>
<div class="box"> <div class="box">
<h2 class="title is-6">Motion</h2> <h2 class="title is-6">Motion</h2>
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<div class="field"> <div class="field">
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" name="disable_animations"{% if accessibility_settings.disable_animations %} checked{% endif %}> <input type="checkbox" name="disable_animations"{% if accessibility_settings.disable_animations %} checked{% endif %}>
Disable animations Disable animations
</label> </label>
<p class="help is-size-7 has-text-grey mt-1"> <p class="help is-size-7 has-text-grey mt-1">
Reduces motion by disabling most transitions and animations across the interface. Reduces motion by disabling most transitions and animations across the interface.
</p> </p>
</div> </div>
<button class="button is-link is-small" type="submit">Save</button> <button class="button is-link is-small" type="submit">Save</button>
</form> </form>
</div>
</div> </div>
</div> </section>
</section>
{% endblock %} {% endblock %}

View File

@@ -1,301 +1,301 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="level"> <div class="level">
<div class="level-left"> <div class="level-left">
<div class="level-item"> <div class="level-item">
<div> <div>
<h1 class="title is-4">Traces</h1> <h1 class="title is-4">Traces</h1>
<p class="subtitle is-6">Tracked model calls and usage metrics for this account.</p> <p class="subtitle is-6">Tracked model calls and usage metrics for this account.</p>
</div>
</div>
</div>
<div class="level-right">
<div class="level-item">
{% if stats.total_runs %}
<span class="tag is-success is-light">Tracking Active</span>
{% else %}
<span class="tag is-warning is-light">No Runs Yet</span>
{% endif %}
</div>
</div> </div>
</div> </div>
</div>
<article class="notification is-light"> <div class="level-right">
<p class="is-size-7 has-text-grey-dark">Execution health at a glance</p> <div class="level-item">
<div class="tags mt-2"> {% if stats.total_runs %}
<span class="tag is-light">Total {{ stats.total_runs }}</span> <span class="tag is-success is-light">Tracking Active</span>
<span class="tag is-success is-light">OK {{ stats.total_ok }}</span> {% else %}
<span class="tag is-danger is-light">Failed {{ stats.total_failed }}</span> <span class="tag is-warning is-light">No Runs Yet</span>
<span class="tag is-info is-light">24h {{ stats.last_24h_runs }}</span> {% endif %}
<span class="tag is-warning is-light">24h Failed {{ stats.last_24h_failed }}</span>
<span class="tag is-link is-light">7d {{ stats.last_7d_runs }}</span>
</div>
<p class="is-size-7 has-text-grey-dark mt-3">Success Rate</p>
<progress class="progress is-link is-small" value="{{ stats.success_rate }}" max="100">{{ stats.success_rate }}%</progress>
</article>
<div class="columns is-multiline">
<div class="column is-12-tablet is-4-desktop">
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">Reliability</p>
</header>
<div class="card-content">
<table class="table is-fullwidth is-narrow is-size-7">
<tbody>
<tr><th>Total Runs</th><td>{{ stats.total_runs }}</td></tr>
<tr><th>OK</th><td class="has-text-success">{{ stats.total_ok }}</td></tr>
<tr><th>Failed</th><td class="has-text-danger">{{ stats.total_failed }}</td></tr>
<tr><th>Success Rate</th><td>{{ stats.success_rate }}%</td></tr>
</tbody>
</table>
</div>
</article>
</div>
<div class="column is-12-tablet is-4-desktop">
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">Throughput</p>
</header>
<div class="card-content">
<table class="table is-fullwidth is-narrow is-size-7">
<tbody>
<tr><th>Runs (24h)</th><td>{{ stats.last_24h_runs }}</td></tr>
<tr><th>Failed (24h)</th><td>{{ stats.last_24h_failed }}</td></tr>
<tr><th>Runs (7d)</th><td>{{ stats.last_7d_runs }}</td></tr>
<tr><th>Avg Duration</th><td>{{ stats.avg_duration_ms }}ms</td></tr>
</tbody>
</table>
</div>
</article>
</div>
<div class="column is-12-tablet is-4-desktop">
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">Token Proxy (Chars)</p>
</header>
<div class="card-content">
<table class="table is-fullwidth is-narrow is-size-7">
<tbody>
<tr><th>Total Prompt</th><td>{{ stats.total_prompt_chars }}</td></tr>
<tr><th>Total Response</th><td>{{ stats.total_response_chars }}</td></tr>
<tr><th>Avg Prompt</th><td>{{ stats.avg_prompt_chars }}</td></tr>
<tr><th>Avg Response</th><td>{{ stats.avg_response_chars }}</td></tr>
</tbody>
</table>
</div>
</article>
</div>
</div> </div>
</div>
</div>
<div class="columns"> <article class="notification is-light">
<div class="column is-6"> <p class="is-size-7 has-text-grey-dark">Execution health at a glance</p>
<article class="card"> <div class="tags mt-2">
<header class="card-header"> <span class="tag is-light">Total {{ stats.total_runs }}</span>
<p class="card-header-title is-size-6">By Operation</p> <span class="tag is-success is-light">OK {{ stats.total_ok }}</span>
</header> <span class="tag is-danger is-light">Failed {{ stats.total_failed }}</span>
<div class="card-content"> <span class="tag is-info is-light">24h {{ stats.last_24h_runs }}</span>
<div class="table-container"> <span class="tag is-warning is-light">24h Failed {{ stats.last_24h_failed }}</span>
<table class="table is-fullwidth is-size-7 is-striped is-hoverable"> <span class="tag is-link is-light">7d {{ stats.last_7d_runs }}</span>
<thead> </div>
<tr><th>Operation</th><th>Total</th><th>OK</th><th>Failed</th></tr> <p class="is-size-7 has-text-grey-dark mt-3">Success Rate</p>
</thead> <progress class="progress is-link is-small" value="{{ stats.success_rate }}" max="100">{{ stats.success_rate }}%</progress>
<tbody> </article>
{% for row in operation_breakdown %}
<tr>
<td>{{ row.operation|default:"(none)" }}</td>
<td>{{ row.total }}</td>
<td class="has-text-success">{{ row.ok }}</td>
<td class="has-text-danger">{{ row.failed }}</td>
</tr>
{% empty %}
<tr><td colspan="4">No runs yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</article>
</div>
<div class="column is-6">
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">By Model</p>
</header>
<div class="card-content">
<div class="table-container">
<table class="table is-fullwidth is-size-7 is-striped is-hoverable">
<thead>
<tr><th>Model</th><th>Total</th><th>OK</th><th>Failed</th></tr>
</thead>
<tbody>
{% for row in model_breakdown %}
<tr>
<td>{{ row.model|default:"(none)" }}</td>
<td>{{ row.total }}</td>
<td class="has-text-success">{{ row.ok }}</td>
<td class="has-text-danger">{{ row.failed }}</td>
</tr>
{% empty %}
<tr><td colspan="4">No runs yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</article>
</div>
</div>
<div class="columns is-multiline">
<div class="column is-12-tablet is-4-desktop">
<article class="card"> <article class="card">
<header class="card-header"> <header class="card-header">
<p class="card-header-title is-size-6">Recent Runs</p> <p class="card-header-title is-size-6">Reliability</p>
</header>
<div class="card-content">
<table class="table is-fullwidth is-narrow is-size-7">
<tbody>
<tr><th>Total Runs</th><td>{{ stats.total_runs }}</td></tr>
<tr><th>OK</th><td class="has-text-success">{{ stats.total_ok }}</td></tr>
<tr><th>Failed</th><td class="has-text-danger">{{ stats.total_failed }}</td></tr>
<tr><th>Success Rate</th><td>{{ stats.success_rate }}%</td></tr>
</tbody>
</table>
</div>
</article>
</div>
<div class="column is-12-tablet is-4-desktop">
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">Throughput</p>
</header>
<div class="card-content">
<table class="table is-fullwidth is-narrow is-size-7">
<tbody>
<tr><th>Runs (24h)</th><td>{{ stats.last_24h_runs }}</td></tr>
<tr><th>Failed (24h)</th><td>{{ stats.last_24h_failed }}</td></tr>
<tr><th>Runs (7d)</th><td>{{ stats.last_7d_runs }}</td></tr>
<tr><th>Avg Duration</th><td>{{ stats.avg_duration_ms }}ms</td></tr>
</tbody>
</table>
</div>
</article>
</div>
<div class="column is-12-tablet is-4-desktop">
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">Token Proxy (Chars)</p>
</header>
<div class="card-content">
<table class="table is-fullwidth is-narrow is-size-7">
<tbody>
<tr><th>Total Prompt</th><td>{{ stats.total_prompt_chars }}</td></tr>
<tr><th>Total Response</th><td>{{ stats.total_response_chars }}</td></tr>
<tr><th>Avg Prompt</th><td>{{ stats.avg_prompt_chars }}</td></tr>
<tr><th>Avg Response</th><td>{{ stats.avg_response_chars }}</td></tr>
</tbody>
</table>
</div>
</article>
</div>
</div>
<div class="columns">
<div class="column is-6">
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">By Operation</p>
</header> </header>
<div class="card-content"> <div class="card-content">
<div class="table-container"> <div class="table-container">
<table class="table is-fullwidth is-size-7 is-striped is-hoverable"> <table class="table is-fullwidth is-size-7 is-striped is-hoverable">
<thead> <thead>
<tr> <tr><th>Operation</th><th>Total</th><th>OK</th><th>Failed</th></tr>
<th></th>
<th>Started</th>
<th>Status</th>
<th>Operation</th>
<th>Model</th>
<th>Messages</th>
<th>Prompt</th>
<th>Response</th>
<th>Duration</th>
<th>Error</th>
</tr>
</thead> </thead>
<tbody> <tbody>
{% for run in runs %} {% for row in operation_breakdown %}
<tr> <tr>
<td> <td>{{ row.operation|default:"(none)" }}</td>
<button <td>{{ row.total }}</td>
class="button is-small is-light trace-run-expand" <td class="has-text-success">{{ row.ok }}</td>
type="button" <td class="has-text-danger">{{ row.failed }}</td>
data-detail-row="trace-run-detail-{{ run.id }}"
data-detail-content="trace-run-detail-content-{{ run.id }}"
data-expanded-label="Hide"
data-collapsed-label="Show"
hx-get="{% url 'ai_execution_run_detail' run_id=run.id %}"
hx-target="#trace-run-detail-content-{{ run.id }}"
hx-swap="innerHTML"
hx-trigger="click once"
>
Show
</button>
</td>
<td>{{ run.started_at }}</td>
<td>
{% if run.status == "ok" %}
<span class="tag is-success is-light">ok</span>
{% elif run.status == "failed" %}
<span class="tag is-danger is-light">failed</span>
{% else %}
<span class="tag is-light">{{ run.status }}</span>
{% endif %}
</td>
<td>{{ run.operation|default:"-" }}</td>
<td>{{ run.model|default:"-" }}</td>
<td>{{ run.message_count }}</td>
<td>{{ run.prompt_chars }}</td>
<td>{{ run.response_chars }}</td>
<td>{% if run.duration_ms %}{{ run.duration_ms }}ms{% else %}-{% endif %}</td>
<td>
{% if run.error %}
<span title="{{ run.error }}">{{ run.error|truncatechars:120 }}</span>
{% else %}
-
{% endif %}
</td>
</tr>
<tr id="trace-run-detail-{{ run.id }}" class="is-hidden">
<td colspan="10">
<div id="trace-run-detail-content-{{ run.id }}" class="trace-run-detail-shell is-size-7 has-text-grey">
Click Show to load run details.
</div>
</td>
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="10">No runs yet.</td></tr> <tr><td colspan="4">No runs yet.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</article> </article>
<script> </div>
(function () { <div class="column is-6">
document.querySelectorAll(".trace-run-expand").forEach(function (button) { <article class="card">
button.addEventListener("click", function () { <header class="card-header">
const rowId = String(button.getAttribute("data-detail-row") || ""); <p class="card-header-title is-size-6">By Model</p>
const row = rowId ? document.getElementById(rowId) : null; </header>
if (!row) { <div class="card-content">
<div class="table-container">
<table class="table is-fullwidth is-size-7 is-striped is-hoverable">
<thead>
<tr><th>Model</th><th>Total</th><th>OK</th><th>Failed</th></tr>
</thead>
<tbody>
{% for row in model_breakdown %}
<tr>
<td>{{ row.model|default:"(none)" }}</td>
<td>{{ row.total }}</td>
<td class="has-text-success">{{ row.ok }}</td>
<td class="has-text-danger">{{ row.failed }}</td>
</tr>
{% empty %}
<tr><td colspan="4">No runs yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</article>
</div>
</div>
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">Recent Runs</p>
</header>
<div class="card-content">
<div class="table-container">
<table class="table is-fullwidth is-size-7 is-striped is-hoverable">
<thead>
<tr>
<th></th>
<th>Started</th>
<th>Status</th>
<th>Operation</th>
<th>Model</th>
<th>Messages</th>
<th>Prompt</th>
<th>Response</th>
<th>Duration</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{% for run in runs %}
<tr>
<td>
<button
class="button is-small is-light trace-run-expand"
type="button"
data-detail-row="trace-run-detail-{{ run.id }}"
data-detail-content="trace-run-detail-content-{{ run.id }}"
data-expanded-label="Hide"
data-collapsed-label="Show"
hx-get="{% url 'ai_execution_run_detail' run_id=run.id %}"
hx-target="#trace-run-detail-content-{{ run.id }}"
hx-swap="innerHTML"
hx-trigger="click once"
>
Show
</button>
</td>
<td>{{ run.started_at }}</td>
<td>
{% if run.status == "ok" %}
<span class="tag is-success is-light">ok</span>
{% elif run.status == "failed" %}
<span class="tag is-danger is-light">failed</span>
{% else %}
<span class="tag is-light">{{ run.status }}</span>
{% endif %}
</td>
<td>{{ run.operation|default:"-" }}</td>
<td>{{ run.model|default:"-" }}</td>
<td>{{ run.message_count }}</td>
<td>{{ run.prompt_chars }}</td>
<td>{{ run.response_chars }}</td>
<td>{% if run.duration_ms %}{{ run.duration_ms }}ms{% else %}-{% endif %}</td>
<td>
{% if run.error %}
<span title="{{ run.error }}">{{ run.error|truncatechars:120 }}</span>
{% else %}
-
{% endif %}
</td>
</tr>
<tr id="trace-run-detail-{{ run.id }}" class="is-hidden">
<td colspan="10">
<div id="trace-run-detail-content-{{ run.id }}" class="trace-run-detail-shell is-size-7 has-text-grey">
Click Show to load run details.
</div>
</td>
</tr>
{% empty %}
<tr><td colspan="10">No runs yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</article>
<script>
(function () {
document.querySelectorAll(".trace-run-expand").forEach(function (button) {
button.addEventListener("click", function () {
const rowId = String(button.getAttribute("data-detail-row") || "");
const row = rowId ? document.getElementById(rowId) : null;
if (!row) {
return;
}
const isHidden = row.classList.contains("is-hidden");
row.classList.toggle("is-hidden", !isHidden);
button.textContent = isHidden
? String(button.getAttribute("data-expanded-label") || "Hide")
: String(button.getAttribute("data-collapsed-label") || "Show");
});
});
document.addEventListener("click", function (event) {
const trigger = event.target.closest(".trace-run-tab-trigger");
if (!trigger) {
return; return;
} }
const isHidden = row.classList.contains("is-hidden"); event.preventDefault();
row.classList.toggle("is-hidden", !isHidden); const shell = trigger.closest(".trace-run-detail-tabs");
button.textContent = isHidden if (!shell) {
? String(button.getAttribute("data-expanded-label") || "Hide") return;
: String(button.getAttribute("data-collapsed-label") || "Show"); }
}); const targetName = String(trigger.getAttribute("data-tab-target") || "");
}); if (!targetName) {
return;
document.addEventListener("click", function (event) { }
const trigger = event.target.closest(".trace-run-tab-trigger"); shell.querySelectorAll(".trace-run-tab-trigger").forEach(function (item) {
if (!trigger) { item.parentElement.classList.toggle("is-active", item === trigger);
return;
}
event.preventDefault();
const shell = trigger.closest(".trace-run-detail-tabs");
if (!shell) {
return;
}
const targetName = String(trigger.getAttribute("data-tab-target") || "");
if (!targetName) {
return;
}
shell.querySelectorAll(".trace-run-tab-trigger").forEach(function (item) {
item.parentElement.classList.toggle("is-active", item === trigger);
});
shell.querySelectorAll(".trace-run-tab-panel").forEach(function (panel) {
const isActive = panel.getAttribute("data-tab-panel") === targetName;
panel.classList.toggle("is-hidden", !isActive);
});
const lazyUrl = String(trigger.getAttribute("data-lazy-url") || "");
if (!lazyUrl) {
return;
}
const panel = shell.querySelector(
'.trace-run-tab-panel[data-tab-panel="' + targetName + '"]'
);
if (!panel || panel.getAttribute("data-loaded") === "1") {
return;
}
panel.setAttribute("data-loaded", "1");
panel.classList.add("is-loading");
fetch(lazyUrl, { credentials: "same-origin" })
.then(function (response) {
if (!response.ok) {
throw new Error("tab load failed");
}
return response.text();
})
.then(function (html) {
panel.innerHTML = html;
})
.catch(function () {
panel.innerHTML =
'<p class="has-text-danger">Unable to load this tab.</p>';
})
.finally(function () {
panel.classList.remove("is-loading");
}); });
}); shell.querySelectorAll(".trace-run-tab-panel").forEach(function (panel) {
})(); const isActive = panel.getAttribute("data-tab-panel") === targetName;
</script> panel.classList.toggle("is-hidden", !isActive);
});
const lazyUrl = String(trigger.getAttribute("data-lazy-url") || "");
if (!lazyUrl) {
return;
}
const panel = shell.querySelector(
'.trace-run-tab-panel[data-tab-panel="' + targetName + '"]'
);
if (!panel || panel.getAttribute("data-loaded") === "1") {
return;
}
panel.setAttribute("data-loaded", "1");
panel.classList.add("is-loading");
fetch(lazyUrl, { credentials: "same-origin" })
.then(function (response) {
if (!response.ok) {
throw new Error("tab load failed");
}
return response.text();
})
.then(function (html) {
panel.innerHTML = html;
})
.catch(function () {
panel.innerHTML =
'<p class="has-text-danger">Unable to load this tab.</p>';
})
.finally(function () {
panel.classList.remove("is-loading");
});
});
})();
</script>
{% endblock %} {% endblock %}

View File

@@ -1,71 +1,71 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<h1 class="title is-4">Availability Settings</h1> <h1 class="title is-4">Availability Settings</h1>
<form method="post" class="box"> <form method="post" class="box">
{% csrf_token %} {% csrf_token %}
<div class="columns is-multiline"> <div class="columns is-multiline">
<div class="column is-3"><label class="checkbox"><input type="checkbox" name="enabled" {% if settings_row.enabled %}checked{% endif %}> Enabled</label></div> <div class="column is-3"><label class="checkbox"><input type="checkbox" name="enabled" {% if settings_row.enabled %}checked{% endif %}> Enabled</label></div>
<div class="column is-3"><label class="checkbox"><input type="checkbox" name="show_in_chat" {% if settings_row.show_in_chat %}checked{% endif %}> Show In Chat</label></div> <div class="column is-3"><label class="checkbox"><input type="checkbox" name="show_in_chat" {% if settings_row.show_in_chat %}checked{% endif %}> Show In Chat</label></div>
<div class="column is-3"><label class="checkbox"><input type="checkbox" name="show_in_groups" {% if settings_row.show_in_groups %}checked{% endif %}> Show In Groups</label></div> <div class="column is-3"><label class="checkbox"><input type="checkbox" name="show_in_groups" {% if settings_row.show_in_groups %}checked{% endif %}> Show In Groups</label></div>
<div class="column is-3"><label class="checkbox"><input type="checkbox" name="inference_enabled" {% if settings_row.inference_enabled %}checked{% endif %}> Inference Enabled</label></div> <div class="column is-3"><label class="checkbox"><input type="checkbox" name="inference_enabled" {% if settings_row.inference_enabled %}checked{% endif %}> Inference Enabled</label></div>
<div class="column is-3"> <div class="column is-3">
<label class="label is-size-7">Retention Days</label> <label class="label is-size-7">Retention Days</label>
<input class="input is-small" type="number" min="1" name="retention_days" value="{{ settings_row.retention_days }}"> <input class="input is-small" type="number" min="1" name="retention_days" value="{{ settings_row.retention_days }}">
</div>
<div class="column is-3">
<label class="label is-size-7">Fade Threshold (seconds)</label>
<input class="input is-small" type="number" min="30" name="fade_threshold_seconds" value="{{ settings_row.fade_threshold_seconds }}">
</div>
</div> </div>
<div class="column is-3"> <button class="button is-link is-small" type="submit">Save</button>
<label class="label is-size-7">Fade Threshold (seconds)</label> </form>
<input class="input is-small" type="number" min="30" name="fade_threshold_seconds" value="{{ settings_row.fade_threshold_seconds }}">
</div>
</div>
<button class="button is-link is-small" type="submit">Save</button>
</form>
<div class="box"> <div class="box">
<h2 class="title is-6">Availability Event Statistics Per Contact</h2> <h2 class="title is-6">Availability Event Statistics Per Contact</h2>
<table class="table is-fullwidth is-striped is-size-7"> <table class="table is-fullwidth is-striped is-size-7">
<thead> <thead>
<tr>
<th>Contact</th>
<th>Service</th>
<th>Total</th>
<th>Available</th>
<th>Fading</th>
<th>Unavailable</th>
<th>Unknown</th>
<th>Native</th>
<th>Read</th>
<th>Typing</th>
<th>Msg Activity</th>
<th>Timeout</th>
<th>Last Event TS</th>
</tr>
</thead>
<tbody>
{% for row in contact_stats %}
<tr> <tr>
<td>{{ row.person__name }}</td> <th>Contact</th>
<td>{{ row.service }}</td> <th>Service</th>
<td>{{ row.total_events }}</td> <th>Total</th>
<td>{{ row.available_events }}</td> <th>Available</th>
<td>{{ row.fading_events }}</td> <th>Fading</th>
<td>{{ row.unavailable_events }}</td> <th>Unavailable</th>
<td>{{ row.unknown_events }}</td> <th>Unknown</th>
<td>{{ row.native_presence_events }}</td> <th>Native</th>
<td>{{ row.read_receipt_events }}</td> <th>Read</th>
<td>{{ row.typing_events }}</td> <th>Typing</th>
<td>{{ row.message_activity_events }}</td> <th>Msg Activity</th>
<td>{{ row.inferred_timeout_events }}</td> <th>Timeout</th>
<td>{{ row.last_event_ts }}</td> <th>Last Event TS</th>
</tr> </tr>
{% empty %} </thead>
<tr><td colspan="13">No availability events found.</td></tr> <tbody>
{% endfor %} {% for row in contact_stats %}
</tbody> <tr>
</table> <td>{{ row.person__name }}</td>
<td>{{ row.service }}</td>
<td>{{ row.total_events }}</td>
<td>{{ row.available_events }}</td>
<td>{{ row.fading_events }}</td>
<td>{{ row.unavailable_events }}</td>
<td>{{ row.unknown_events }}</td>
<td>{{ row.native_presence_events }}</td>
<td>{{ row.read_receipt_events }}</td>
<td>{{ row.typing_events }}</td>
<td>{{ row.message_activity_events }}</td>
<td>{{ row.inferred_timeout_events }}</td>
<td>{{ row.last_event_ts }}</td>
</tr>
{% empty %}
<tr><td colspan="13">No availability events found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div> </div>
</div> </section>
</section>
{% endblock %} {% endblock %}

View File

@@ -28,7 +28,7 @@
<textarea class="textarea" name="content_markdown" rows="18">{{ document.content_markdown }}</textarea> <textarea class="textarea" name="content_markdown" rows="18">{{ document.content_markdown }}</textarea>
<div class="buttons" style="margin-top: 0.75rem;"> <div class="buttons" style="margin-top: 0.75rem;">
<button class="button is-link" type="submit">Save Revision</button> <button class="button is-link" type="submit">Save Revision</button>
<a class="button is-light" href="{% url 'command_routing' %}">Back</a> <a class="button is-light" href="{% url 'business_plan_inbox' %}">Back To Inbox</a>
</div> </div>
</form> </form>
</article> </article>

View File

@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title is-4">Business Plan Inbox</h1>
<p class="subtitle is-6">Review, filter, and open generated business plan documents.</p>
<article class="notification is-light">
<div class="tags mb-1">
<span class="tag is-light">Total {{ stats.total|default:0 }}</span>
<span class="tag is-warning is-light">Draft {{ stats.draft|default:0 }}</span>
<span class="tag is-success is-light">Final {{ stats.final|default:0 }}</span>
</div>
</article>
<article class="box">
<h2 class="title is-6">Filters</h2>
<form method="get">
<div class="columns is-multiline">
<div class="column is-4">
<label class="label is-size-7">Search</label>
<input class="input is-small" name="q" value="{{ filters.q }}" placeholder="Title, source channel, command profile">
</div>
<div class="column is-3">
<label class="label is-size-7">Status</label>
<div class="select is-small is-fullwidth">
<select name="status">
<option value="">All</option>
<option value="draft" {% if filters.status == "draft" %}selected{% endif %}>Draft</option>
<option value="final" {% if filters.status == "final" %}selected{% endif %}>Final</option>
</select>
</div>
</div>
<div class="column is-3">
<label class="label is-size-7">Service</label>
<div class="select is-small is-fullwidth">
<select name="service">
<option value="">All</option>
{% for service in service_choices %}
<option value="{{ service }}" {% if filters.service == service %}selected{% endif %}>{{ service }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-2 is-flex is-align-items-flex-end">
<button class="button is-small is-link is-light" type="submit">Apply</button>
</div>
</div>
</form>
</article>
<article class="box">
<div class="is-flex is-justify-content-space-between is-align-items-center mb-2">
<h2 class="title is-6 mb-0">Documents</h2>
<a class="button is-small is-light" href="{% url 'command_routing' %}">Command Routing</a>
</div>
<table class="table is-fullwidth is-striped is-size-7">
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Service</th>
<th>Channel</th>
<th>Profile</th>
<th>Revisions</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody>
{% for doc in documents %}
<tr>
<td>{{ doc.title }}</td>
<td>
{% if doc.status == "final" %}
<span class="tag is-success is-light">final</span>
{% else %}
<span class="tag is-warning is-light">draft</span>
{% endif %}
</td>
<td>{{ doc.source_service }}</td>
<td><code>{{ doc.source_channel_identifier|default:"-" }}</code></td>
<td>{% if doc.command_profile %}{{ doc.command_profile.name }}{% else %}-{% endif %}</td>
<td>{{ doc.revision_count }}</td>
<td>{{ doc.updated_at }}</td>
<td>
<a class="button is-small is-link is-light" href="{% url 'business_plan_editor' doc_id=doc.id %}">Open</a>
</td>
</tr>
{% empty %}
<tr><td colspan="8">No business plan documents yet.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div>
</section>
{% endblock %}

View File

@@ -1,144 +1,144 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<h1 class="title is-4">Codex Status</h1> <h1 class="title is-4">Codex Status</h1>
<p class="subtitle is-6">Global per-user Codex task-sync status, runs, and approvals.</p> <p class="subtitle is-6">Global per-user Codex task-sync status, runs, and approvals.</p>
<article class="box"> <article class="box">
<div class="codex-inline-stats"> <div class="codex-inline-stats">
<span><strong>Provider</strong> codex_cli</span> <span><strong>Provider</strong> codex_cli</span>
<span><strong>Health</strong> <span class="{% if health and health.ok %}has-text-success{% else %}has-text-danger{% endif %}">{% if health and health.ok %}online{% else %}offline{% endif %}</span></span> <span><strong>Health</strong> <span class="{% if health and health.ok %}has-text-success{% else %}has-text-danger{% endif %}">{% if health and health.ok %}online{% else %}offline{% endif %}</span></span>
<span><strong>Pending</strong> {{ queue_counts.pending }}</span> <span><strong>Pending</strong> {{ queue_counts.pending }}</span>
<span><strong>Waiting Approval</strong> {{ queue_counts.waiting_approval }}</span> <span><strong>Waiting Approval</strong> {{ queue_counts.waiting_approval }}</span>
</div> </div>
{% if health and health.error %} {% if health and health.error %}
<p class="help">Healthcheck error: <code>{{ health.error }}</code></p> <p class="help">Healthcheck error: <code>{{ health.error }}</code></p>
{% endif %} {% endif %}
<p class="help">Config snapshot: command=<code>{{ provider_settings.command }}</code>, workspace=<code>{{ provider_settings.workspace_root|default:"-" }}</code>, profile=<code>{{ provider_settings.default_profile|default:"-" }}</code>, instance=<code>{{ provider_settings.instance_label }}</code>, approver=<code>{{ provider_settings.approver_service }} {{ provider_settings.approver_identifier }}</code>.</p> <p class="help">Config snapshot: command=<code>{{ provider_settings.command }}</code>, workspace=<code>{{ provider_settings.workspace_root|default:"-" }}</code>, profile=<code>{{ provider_settings.default_profile|default:"-" }}</code>, instance=<code>{{ provider_settings.instance_label }}</code>, approver=<code>{{ provider_settings.approver_service }} {{ provider_settings.approver_identifier }}</code>.</p>
<p class="help"><a href="{% url 'tasks_settings' %}">Edit in Task Settings</a>.</p> <p class="help"><a href="{% url 'tasks_settings' %}">Edit in Task Automation</a>.</p>
</article> </article>
<article class="box"> <article class="box">
<h2 class="title is-6">Run Filters</h2> <h2 class="title is-6">Run Filters</h2>
<form method="get"> <form method="get">
<div class="columns is-multiline"> <div class="columns is-multiline">
<div class="column is-2"> <div class="column is-2">
<label class="label is-size-7">Status</label> <label class="label is-size-7">Status</label>
<input class="input is-small" name="status" value="{{ filters.status }}" placeholder="ok/failed/..."> <input class="input is-small" name="status" value="{{ filters.status }}" placeholder="ok/failed/...">
</div> </div>
<div class="column is-2"> <div class="column is-2">
<label class="label is-size-7">Service</label> <label class="label is-size-7">Service</label>
<input class="input is-small" name="service" value="{{ filters.service }}" placeholder="signal"> <input class="input is-small" name="service" value="{{ filters.service }}" placeholder="signal">
</div> </div>
<div class="column is-3"> <div class="column is-3">
<label class="label is-size-7">Channel</label> <label class="label is-size-7">Channel</label>
<input class="input is-small" name="channel" value="{{ filters.channel }}" placeholder="identifier"> <input class="input is-small" name="channel" value="{{ filters.channel }}" placeholder="identifier">
</div> </div>
<div class="column is-3"> <div class="column is-3">
<label class="label is-size-7">Project</label> <label class="label is-size-7">Project</label>
<div class="select is-small is-fullwidth"> <div class="select is-small is-fullwidth">
<select name="project"> <select name="project">
<option value="">All</option> <option value="">All</option>
{% for row in projects %} {% for row in projects %}
<option value="{{ row.id }}" {% if filters.project == row.id|stringformat:"s" %}selected{% endif %}>{{ row.name }}</option> <option value="{{ row.id }}" {% if filters.project == row.id|stringformat:"s" %}selected{% endif %}>{{ row.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div>
</div>
<div class="column is-2">
<label class="label is-size-7">Date From</label>
<input class="input is-small" type="date" name="date_from" value="{{ filters.date_from }}">
</div> </div>
</div> </div>
<div class="column is-2"> <button class="button is-small is-link is-light" type="submit">Apply</button>
<label class="label is-size-7">Date From</label> </form>
<input class="input is-small" type="date" name="date_from" value="{{ filters.date_from }}"> </article>
</div>
</div>
<button class="button is-small is-link is-light" type="submit">Apply</button>
</form>
</article>
<article class="box"> <article class="box">
<h2 class="title is-6">Runs</h2> <h2 class="title is-6">Runs</h2>
<table class="table is-fullwidth is-size-7 is-striped"> <table class="table is-fullwidth is-size-7 is-striped">
<thead><tr><th>When</th><th>Status</th><th>Service/Channel</th><th>Project</th><th>Task</th><th>Summary</th><th>Files</th><th>Links</th></tr></thead> <thead><tr><th>When</th><th>Status</th><th>Service/Channel</th><th>Project</th><th>Task</th><th>Summary</th><th>Files</th><th>Links</th></tr></thead>
<tbody> <tbody>
{% for run in runs %} {% for run in runs %}
<tr> <tr>
<td>{{ run.created_at }}</td> <td>{{ run.created_at }}</td>
<td>{{ run.status }}</td> <td>{{ run.status }}</td>
<td>{{ run.source_service }} · <code>{{ run.source_channel }}</code></td> <td>{{ run.source_service }} · <code>{{ run.source_channel }}</code></td>
<td>{{ run.project.name|default:"-" }}</td> <td>{{ run.project.name|default:"-" }}</td>
<td>{% if run.task %}<a href="{% url 'tasks_task' task_id=run.task.id %}">#{{ run.task.reference_code }}</a>{% else %}-{% endif %}</td> <td>{% if run.task %}<a href="{% url 'tasks_task' task_id=run.task.id %}">#{{ run.task.reference_code }}</a>{% else %}-{% endif %}</td>
<td>{{ run.result_payload.summary|default:"-" }}</td> <td>{{ run.result_payload.summary|default:"-" }}</td>
<td>{{ run.result_payload.files_modified_count|default:"0" }}</td> <td>{{ run.result_payload.files_modified_count|default:"0" }}</td>
<td> <td>
<details> <details>
<summary>Details</summary> <summary>Details</summary>
<p><strong>Request</strong></p> <p><strong>Request</strong></p>
<pre>{{ run.request_payload }}</pre> <pre>{{ run.request_payload }}</pre>
<p><strong>Result</strong></p> <p><strong>Result</strong></p>
<pre>{{ run.result_payload }}</pre> <pre>{{ run.result_payload }}</pre>
<p><strong>Error</strong> {{ run.error|default:"-" }}</p> <p><strong>Error</strong> {{ run.error|default:"-" }}</p>
</details> </details>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="8">No runs.</td></tr> <tr><td colspan="8">No runs.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</article> </article>
<article class="box"> <article class="box">
<h2 class="title is-6">Permission Queue</h2> <h2 class="title is-6">Approvals Queue</h2>
<table class="table is-fullwidth is-size-7 is-striped"> <table class="table is-fullwidth is-size-7 is-striped">
<thead><tr><th>Requested</th><th>Approval Key</th><th>Status</th><th>Summary</th><th>Permissions</th><th>Run</th><th>Task</th><th>Actions</th></tr></thead> <thead><tr><th>Requested</th><th>Approval Key</th><th>Status</th><th>Summary</th><th>Permissions</th><th>Run</th><th>Task</th><th>Actions</th></tr></thead>
<tbody> <tbody>
{% for row in permission_requests %} {% for row in permission_requests %}
<tr> <tr>
<td>{{ row.requested_at }}</td> <td>{{ row.requested_at }}</td>
<td><code>{{ row.approval_key }}</code></td> <td><code>{{ row.approval_key }}</code></td>
<td>{{ row.status }}</td> <td>{{ row.status }}</td>
<td>{{ row.summary|default:"-" }}</td> <td>{{ row.summary|default:"-" }}</td>
<td><pre>{{ row.requested_permissions }}</pre></td> <td><pre>{{ row.requested_permissions }}</pre></td>
<td><code>{{ row.codex_run_id }}</code></td> <td><code>{{ row.codex_run_id }}</code></td>
<td>{% if row.codex_run.task %}<a href="{% url 'tasks_task' task_id=row.codex_run.task.id %}">#{{ row.codex_run.task.reference_code }}</a>{% else %}-{% endif %}</td> <td>{% if row.codex_run.task %}<a href="{% url 'tasks_task' task_id=row.codex_run.task.id %}">#{{ row.codex_run.task.reference_code }}</a>{% else %}-{% endif %}</td>
<td> <td>
{% if row.status == 'pending' %} {% if row.status == 'pending' %}
<form method="post" action="{% url 'codex_approval' %}" style="display:inline;"> <form method="post" action="{% url 'codex_approval' %}" style="display:inline;">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="request_id" value="{{ row.id }}"> <input type="hidden" name="request_id" value="{{ row.id }}">
<input type="hidden" name="decision" value="approve"> <input type="hidden" name="decision" value="approve">
<button class="button is-small is-success is-light" type="submit">Approve</button> <button class="button is-small is-success is-light" type="submit">Approve</button>
</form> </form>
<form method="post" action="{% url 'codex_approval' %}" style="display:inline;"> <form method="post" action="{% url 'codex_approval' %}" style="display:inline;">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="request_id" value="{{ row.id }}"> <input type="hidden" name="request_id" value="{{ row.id }}">
<input type="hidden" name="decision" value="deny"> <input type="hidden" name="decision" value="deny">
<button class="button is-small is-danger is-light" type="submit">Deny</button> <button class="button is-small is-danger is-light" type="submit">Deny</button>
</form> </form>
{% else %} {% else %}
- -
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="8">No permission requests.</td></tr> <tr><td colspan="8">No permission requests.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</article> </article>
</div> </div>
</section> </section>
<style> <style>
.codex-inline-stats { .codex-inline-stats {
display: flex; display: flex;
gap: 0.95rem; gap: 0.95rem;
flex-wrap: wrap; flex-wrap: wrap;
font-size: 0.92rem; font-size: 0.92rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.codex-inline-stats span { .codex-inline-stats span {
white-space: nowrap; white-space: nowrap;
} }
</style> </style>
{% endblock %} {% endblock %}

View File

@@ -393,7 +393,10 @@
{% endfor %} {% endfor %}
<article class="box" id="bp-documents"> <article class="box" id="bp-documents">
<h2 class="title is-6">Business Plan Documents</h2> <div class="is-flex is-justify-content-space-between is-align-items-center mb-2">
<h2 class="title is-6 mb-0">Recent Business Plan Documents</h2>
<a class="button is-small is-link is-light" href="{% url 'business_plan_inbox' %}">Open Business Plan Inbox</a>
</div>
<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">Title</th><th scope="col">Status</th><th scope="col">Source</th><th scope="col">Updated</th><th scope="col">Actions</th></tr> <tr><th scope="col">Title</th><th scope="col">Status</th><th scope="col">Source</th><th scope="col">Updated</th><th scope="col">Actions</th></tr>

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,22 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<h1 class="title is-4">{{ category_title }}</h1> <h1 class="title is-4">{{ category_title }}</h1>
<p class="subtitle is-6">{{ category_description }}</p> <p class="subtitle is-6">{{ category_description }}</p>
<div class="tabs is-boxed is-small mb-4 security-page-tabs"> <div class="tabs is-boxed is-small mb-4 security-page-tabs">
<ul> <ul>
{% for tab in category_tabs %} {% for tab in category_tabs %}
<li class="{% if tab.active %}is-active{% endif %}"> <li class="{% if tab.active %}is-active{% endif %}">
<a href="{{ tab.href }}">{{ tab.label }}</a> <a href="{{ tab.href }}">{{ tab.label }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
</div>
<div class="box">
<p class="is-size-7 has-text-grey">Choose a tab above to open settings in this category.</p>
</div>
</div> </div>
<div class="box"> </section>
<p class="is-size-7 has-text-grey">Choose a tab above to open settings in this category.</p>
</div>
</div>
</section>
{% endblock %} {% endblock %}

View File

@@ -1,127 +1,127 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<section class="section"><div class="container"> <section class="section"><div class="container">
<h1 class="title is-4">Task #{{ task.reference_code }}: {{ task.title }}</h1> <h1 class="title is-4">Task #{{ task.reference_code }}: {{ task.title }}</h1>
<p class="subtitle is-6">{{ task.project.name }}{% if task.epic %} / {{ task.epic.name }}{% endif %} · {{ task.status_snapshot }}</p> <p class="subtitle is-6">{{ task.project.name }}{% if task.epic %} / {{ task.epic.name }}{% endif %} · {{ task.status_snapshot }}</p>
<p class="is-size-7 has-text-grey" style="margin-top:-0.65rem; margin-bottom: 0.65rem;"> <p class="is-size-7 has-text-grey" style="margin-top:-0.65rem; margin-bottom: 0.65rem;">
Created by {{ task.creator_label|default:"Unknown" }} Created by {{ task.creator_label|default:"Unknown" }}
{% if task.origin_message_id %} {% if task.origin_message_id %}
· Source message <code>{{ task.origin_message_id }}</code> · Source message <code>{{ task.origin_message_id }}</code>
{% endif %} {% endif %}
</p> </p>
<div class="buttons"> <div class="buttons">
<a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a> <a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a>
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;"> <form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="task_id" value="{{ task.id }}"> <input type="hidden" name="task_id" value="{{ task.id }}">
<input type="hidden" name="next" value="{% url 'tasks_task' task_id=task.id %}"> <input type="hidden" name="next" value="{% url 'tasks_task' task_id=task.id %}">
<button class="button is-small is-link is-light" type="submit">Send to Codex</button> <button class="button is-small is-link is-light" type="submit">Send to Codex</button>
</form> </form>
</div> </div>
<article class="box"> <article class="box">
<h2 class="title is-6">Events</h2> <h2 class="title is-6">Events</h2>
<table class="table is-fullwidth is-size-7"> <table class="table is-fullwidth is-size-7">
<thead><tr><th>When</th><th>Type</th><th>Actor</th><th>Payload</th></tr></thead> <thead><tr><th>When</th><th>Type</th><th>Actor</th><th>Payload</th></tr></thead>
<tbody> <tbody>
{% for row in events %} {% for row in events %}
<tr> <tr>
<td>{{ row.created_at }}</td> <td>{{ row.created_at }}</td>
<td>{{ row.event_type }}</td> <td>{{ row.event_type }}</td>
<td> <td>
{{ row.actor_display|default:"Unknown" }} {{ row.actor_display|default:"Unknown" }}
{% if row.actor_identifier and row.actor_identifier != row.actor_display %} {% if row.actor_identifier and row.actor_identifier != row.actor_display %}
<div class="has-text-grey"><code>{{ row.actor_identifier }}</code></div> <div class="has-text-grey"><code>{{ row.actor_identifier }}</code></div>
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if row.payload_view.summary_items %} {% if row.payload_view.summary_items %}
<div class="tags" style="margin-bottom: 0.35rem;"> <div class="tags" style="margin-bottom: 0.35rem;">
{% for item in row.payload_view.summary_items %} {% for item in row.payload_view.summary_items %}
<span class="tag task-ui-badge"><strong>{{ item.0 }}</strong>: {{ item.1 }}</span> <span class="tag task-ui-badge"><strong>{{ item.0 }}</strong>: {{ item.1 }}</span>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
<details> <details>
<summary class="is-size-7 has-text-link" style="cursor:pointer;">View payload JSON</summary> <summary class="is-size-7 has-text-link" style="cursor:pointer;">View payload JSON</summary>
<pre class="task-event-payload">{{ row.payload_view.pretty_text }}</pre> <pre class="task-event-payload">{{ row.payload_view.pretty_text }}</pre>
</details> </details>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="4">No events.</td></tr> <tr><td colspan="4">No events.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</article> </article>
<article class="box"> <article class="box">
<h2 class="title is-6">External Sync</h2> <h2 class="title is-6">External Sync</h2>
<table class="table is-fullwidth is-size-7"> <table class="table is-fullwidth is-size-7">
<thead><tr><th>When</th><th>Provider</th><th>Status</th><th>Error</th></tr></thead> <thead><tr><th>When</th><th>Provider</th><th>Status</th><th>Error</th></tr></thead>
<tbody> <tbody>
{% for row in sync_events %} {% for row in sync_events %}
<tr><td>{{ row.updated_at }}</td><td>{{ row.provider }}</td><td>{{ row.status }}</td><td>{{ row.error }}</td></tr> <tr><td>{{ row.updated_at }}</td><td>{{ row.provider }}</td><td>{{ row.status }}</td><td>{{ row.error }}</td></tr>
{% empty %} {% empty %}
<tr><td colspan="4">No sync events.</td></tr> <tr><td colspan="4">No sync events.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</article> </article>
<article class="box"> <article class="box">
<h2 class="title is-6">Codex Runs</h2> <h2 class="title is-6">Codex Runs</h2>
<table class="table is-fullwidth is-size-7"> <table class="table is-fullwidth is-size-7">
<thead><tr><th>When</th><th>Status</th><th>Summary</th><th>Files</th><th>Error</th></tr></thead> <thead><tr><th>When</th><th>Status</th><th>Summary</th><th>Files</th><th>Error</th></tr></thead>
<tbody> <tbody>
{% for row in codex_runs %} {% for row in codex_runs %}
<tr> <tr>
<td>{{ row.updated_at }}</td> <td>{{ row.updated_at }}</td>
<td>{{ row.status }}</td> <td>{{ row.status }}</td>
<td>{{ row.result_payload.summary|default:"-" }}</td> <td>{{ row.result_payload.summary|default:"-" }}</td>
<td>{{ row.result_payload.files_modified_count|default:"0" }}</td> <td>{{ row.result_payload.files_modified_count|default:"0" }}</td>
<td>{{ row.error|default:"" }}</td> <td>{{ row.error|default:"" }}</td>
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="5">No Codex runs.</td></tr> <tr><td colspan="5">No Codex runs.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</article> </article>
<article class="box"> <article class="box">
<h2 class="title is-6">Permission Requests</h2> <h2 class="title is-6">Permission Requests</h2>
<table class="table is-fullwidth is-size-7"> <table class="table is-fullwidth is-size-7">
<thead><tr><th>When</th><th>Approval Key</th><th>Status</th><th>Summary</th><th>Resolved</th></tr></thead> <thead><tr><th>When</th><th>Approval Key</th><th>Status</th><th>Summary</th><th>Resolved</th></tr></thead>
<tbody> <tbody>
{% for row in permission_requests %} {% for row in permission_requests %}
<tr> <tr>
<td>{{ row.requested_at }}</td> <td>{{ row.requested_at }}</td>
<td><code>{{ row.approval_key }}</code></td> <td><code>{{ row.approval_key }}</code></td>
<td>{{ row.status }}</td> <td>{{ row.status }}</td>
<td>{{ row.summary|default:"-" }}</td> <td>{{ row.summary|default:"-" }}</td>
<td>{{ row.resolved_at|default:"-" }}</td> <td>{{ row.resolved_at|default:"-" }}</td>
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="5">No permission requests.</td></tr> <tr><td colspan="5">No permission requests.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</article> </article>
</div></section> </div></section>
<style> <style>
.task-event-payload { .task-event-payload {
margin-top: 0.35rem; margin-top: 0.35rem;
padding: 0.6rem; padding: 0.6rem;
border-radius: 8px; border-radius: 8px;
border: 1px solid rgba(127, 127, 127, 0.25); border: 1px solid rgba(127, 127, 127, 0.25);
background: rgba(245, 245, 245, 0.75); background: rgba(245, 245, 245, 0.75);
color: #1f1f1f; color: #1f1f1f;
max-width: 72ch; max-width: 72ch;
overflow: auto; overflow: auto;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
[data-theme="dark"] .task-event-payload { [data-theme="dark"] .task-event-payload {
background: rgba(35, 35, 35, 0.75); background: rgba(35, 35, 35, 0.75);
color: #f5f5f5; color: #f5f5f5;
border-color: rgba(200, 200, 200, 0.35); border-color: rgba(200, 200, 200, 0.35);
} }
</style> </style>
{% endblock %} {% endblock %}

View File

@@ -1,22 +1,22 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<section class="section"><div class="container"> <section class="section"><div class="container">
<h1 class="title is-4">Epic: {{ epic.name }}</h1> <h1 class="title is-4">Epic: {{ epic.name }}</h1>
<div class="buttons"><a class="button is-small is-light" href="{% url 'tasks_project' project_id=epic.project_id %}">Back to project</a></div> <div class="buttons"><a class="button is-small is-light" href="{% url 'tasks_project' project_id=epic.project_id %}">Back to project</a></div>
<article class="box"> <article class="box">
<ul class="is-size-7"> <ul class="is-size-7">
{% for row in tasks %} {% for row in tasks %}
<li> <li>
<a href="{% url 'tasks_task' task_id=row.id %}">#{{ row.reference_code }} {{ row.title }}</a> <a href="{% url 'tasks_task' task_id=row.id %}">#{{ row.reference_code }} {{ row.title }}</a>
<span class="has-text-grey">· by {{ row.creator_label|default:"Unknown" }}</span> <span class="has-text-grey">· by {{ row.creator_label|default:"Unknown" }}</span>
{% if row.creator_identifier %} {% if row.creator_identifier %}
<code>{{ row.creator_identifier }}</code> <code>{{ row.creator_identifier }}</code>
{% endif %} {% endif %}
</li> </li>
{% empty %} {% empty %}
<li>No tasks.</li> <li>No tasks.</li>
{% endfor %} {% endfor %}
</ul> </ul>
</article> </article>
</div></section> </div></section>
{% endblock %} {% endblock %}

View File

@@ -1,84 +1,84 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<section class="section"><div class="container"> <section class="section"><div class="container">
<h1 class="title is-4">Group Tasks: {{ channel_display_name }}</h1> <h1 class="title is-4">Group Task Inbox: {{ channel_display_name }}</h1>
<p class="subtitle is-6">{{ service_label }}</p> <p class="subtitle is-6">{{ service_label }}</p>
<article class="box"> <article class="box">
<h2 class="title is-6">Create Or Map Project</h2> <h2 class="title is-6">Create Or Map Project</h2>
{% if primary_project %} {% if primary_project %}
<form method="post" style="margin-bottom: 0.7rem;">
{% csrf_token %}
<input type="hidden" name="action" value="group_project_rename">
<div class="columns is-multiline">
<div class="column is-7">
<label class="label is-size-7">Rename Current Chat Project</label>
<input class="input is-small" name="project_name" value="{{ primary_project.name }}">
</div>
<div class="column is-5" style="display:flex; align-items:flex-end;">
<button class="button is-small is-light" type="submit">Rename</button>
</div>
</div>
</form>
{% endif %}
<form method="post" style="margin-bottom: 0.7rem;"> <form method="post" style="margin-bottom: 0.7rem;">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="group_project_rename"> <input type="hidden" name="action" value="group_project_create">
<div class="columns is-multiline"> <div class="columns is-multiline">
<div class="column is-7"> <div class="column is-5">
<label class="label is-size-7">Rename Current Chat Project</label> <label class="label is-size-7">Project Name</label>
<input class="input is-small" name="project_name" value="{{ primary_project.name }}"> <input class="input is-small" name="project_name" placeholder="Project name">
</div> </div>
<div class="column is-5" style="display:flex; align-items:flex-end;"> <div class="column is-5">
<button class="button is-small is-light" type="submit">Rename</button> <label class="label is-size-7">Initial Epic (optional)</label>
<input class="input is-small" name="epic_name" placeholder="Epic name">
</div>
<div class="column is-2" style="display:flex; align-items:flex-end;">
<button class="button is-small is-link is-light" type="submit">Create + Map</button>
</div> </div>
</div> </div>
</form> </form>
{% endif %} <form method="post">
<form method="post" style="margin-bottom: 0.7rem;"> {% csrf_token %}
{% csrf_token %} <input type="hidden" name="action" value="group_map_existing_project">
<input type="hidden" name="action" value="group_project_create"> <div class="columns is-multiline">
<div class="columns is-multiline"> <div class="column is-9">
<div class="column is-5"> <label class="label is-size-7">Existing Project</label>
<label class="label is-size-7">Project Name</label> <div class="select is-small is-fullwidth">
<input class="input is-small" name="project_name" placeholder="Project name"> <select name="project_id">
</div> {% for project in projects %}
<div class="column is-5"> <option value="{{ project.id }}">{{ project.name }}</option>
<label class="label is-size-7">Initial Epic (optional)</label> {% empty %}
<input class="input is-small" name="epic_name" placeholder="Epic name"> <option value="">No projects available</option>
</div> {% endfor %}
<div class="column is-2" style="display:flex; align-items:flex-end;"> </select>
<button class="button is-small is-link is-light" type="submit">Create + Map</button> </div>
</div> </div>
</div> <div class="column is-3" style="display:flex; align-items:flex-end;">
</form> <button class="button is-small is-light" type="submit">Map Existing</button>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="group_map_existing_project">
<div class="columns is-multiline">
<div class="column is-9">
<label class="label is-size-7">Existing Project</label>
<div class="select is-small is-fullwidth">
<select name="project_id">
{% for project in projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% empty %}
<option value="">No projects available</option>
{% endfor %}
</select>
</div> </div>
</div> </div>
<div class="column is-3" style="display:flex; align-items:flex-end;"> </form>
<button class="button is-small is-light" type="submit">Map Existing</button>
</div>
</div>
</form>
</article>
{% if not tasks %}
<article class="box">
<h2 class="title is-6">No Tasks Yet</h2>
<div class="content is-size-7">
<p>This group has no derived tasks yet. To start populating this view:</p>
<ol>
<li>Open <a href="{% url 'tasks_settings' %}?service={{ service }}&identifier={{ identifier|urlencode }}">Task Settings</a> and confirm this chat is mapped under <strong>Group Mapping</strong>.</li>
<li>Send task-like messages in this group, for example: <code>task: ship v1</code>, <code>todo: write tests</code>, <code>please review PR</code>.</li>
<li>Mark completion explicitly with a phrase + reference, for example: <code>done #12</code>, <code>completed #12</code>, <code>fixed #12</code>.</li>
<li>Refresh this page; new derived tasks and events should appear automatically.</li>
</ol>
</div>
</article> </article>
{% endif %} {% if not tasks %}
<article class="box"> <article class="box">
<h2 class="title is-6">Mappings</h2> <h2 class="title is-6">No Tasks Yet</h2>
<table class="table is-fullwidth is-striped is-size-7"> <div class="content is-size-7">
<thead><tr><th>Project</th><th>Epic</th><th>Channel</th><th>Enabled</th><th></th></tr></thead> <p>This group has no derived tasks yet. To start populating this view:</p>
<tbody> <ol>
{% for row in mappings %} <li>Open <a href="{% url 'tasks_settings' %}?service={{ service }}&identifier={{ identifier|urlencode }}">Task Automation</a> and confirm this chat is mapped under <strong>Group Mapping</strong>.</li>
<li>Send task-like messages in this group, for example: <code>task: ship v1</code>, <code>todo: write tests</code>, <code>please review PR</code>.</li>
<li>Mark completion explicitly with a phrase + reference, for example: <code>done #12</code>, <code>completed #12</code>, <code>fixed #12</code>.</li>
<li>Refresh this page; new derived tasks and events should appear automatically.</li>
</ol>
</div>
</article>
{% endif %}
<article class="box">
<h2 class="title is-6">Mappings</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Project</th><th>Epic</th><th>Channel</th><th>Enabled</th><th></th></tr></thead>
<tbody>
{% for row in mappings %}
<tr> <tr>
<td>{{ row.project.name }}</td> <td>{{ row.project.name }}</td>
<td>{% if row.epic %}{{ row.epic.name }}{% else %}-{% endif %}</td> <td>{% if row.epic %}{{ row.epic.name }}{% else %}-{% endif %}</td>
@@ -88,36 +88,36 @@
<td>{{ row.enabled }}</td> <td>{{ row.enabled }}</td>
<td><a class="button is-small is-light" href="{% url 'tasks_project' project_id=row.project_id %}">Open Project</a></td> <td><a class="button is-small is-light" href="{% url 'tasks_project' project_id=row.project_id %}">Open Project</a></td>
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="5">No mappings for this group.</td></tr> <tr><td colspan="5">No mappings for this group.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</article> </article>
<article class="box"> <article class="box">
<h2 class="title is-6">Derived Tasks</h2> <h2 class="title is-6">Derived Tasks</h2>
<table class="table is-fullwidth is-striped is-size-7"> <table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Ref</th><th>Title</th><th>Created By</th><th>Project</th><th>Status</th><th></th></tr></thead> <thead><tr><th>Ref</th><th>Title</th><th>Created By</th><th>Project</th><th>Status</th><th></th></tr></thead>
<tbody> <tbody>
{% for row in tasks %} {% for row in tasks %}
<tr> <tr>
<td>#{{ row.reference_code }}</td> <td>#{{ row.reference_code }}</td>
<td>{{ row.title }}</td> <td>{{ row.title }}</td>
<td> <td>
{{ row.creator_label|default:"Unknown" }} {{ row.creator_label|default:"Unknown" }}
{% if row.creator_identifier %} {% if row.creator_identifier %}
<div class="has-text-grey"><code>{{ row.creator_identifier }}</code></div> <div class="has-text-grey"><code>{{ row.creator_identifier }}</code></div>
{% endif %} {% endif %}
</td> </td>
<td>{{ row.project.name }}{% if row.epic %} / {{ row.epic.name }}{% endif %}</td> <td>{{ row.project.name }}{% if row.epic %} / {{ row.epic.name }}{% endif %}</td>
<td>{{ row.status_snapshot }}</td> <td>{{ row.status_snapshot }}</td>
<td><a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a></td> <td><a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a></td>
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="6">No tasks yet.</td></tr> <tr><td colspan="6">No tasks yet.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</article> </article>
</div></section> </div></section>
{% endblock %} {% endblock %}

View File

@@ -1,204 +1,204 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<h1 class="title is-4">Tasks</h1> <h1 class="title is-4">Task Inbox</h1>
<p class="subtitle is-6">Immutable tasks derived from chat activity.</p> <p class="subtitle is-6">Immutable tasks derived from chat activity.</p>
<div class="buttons" style="margin-bottom: 0.75rem;"> <div class="buttons" style="margin-bottom: 0.75rem;">
<a class="button is-small is-link is-light" href="{% url 'tasks_settings' %}{% if scope.person_id or scope.service or scope.identifier %}?{% if scope.person_id %}person={{ scope.person_id|urlencode }}{% endif %}{% if scope.service %}{% if scope.person_id %}&{% endif %}service={{ scope.service|urlencode }}{% endif %}{% if scope.identifier %}{% if scope.person_id or scope.service %}&{% endif %}identifier={{ scope.identifier|urlencode }}{% endif %}{% endif %}">Task Settings</a> <a class="button is-small is-link is-light" href="{% url 'tasks_settings' %}{% if scope.person_id or scope.service or scope.identifier %}?{% if scope.person_id %}person={{ scope.person_id|urlencode }}{% endif %}{% if scope.service %}{% if scope.person_id %}&{% endif %}service={{ scope.service|urlencode }}{% endif %}{% if scope.identifier %}{% if scope.person_id or scope.service %}&{% endif %}identifier={{ scope.identifier|urlencode }}{% endif %}{% endif %}">Task Automation</a>
</div> </div>
<div class="columns is-variable is-5"> <div class="columns is-variable is-5">
<div class="column is-4"> <div class="column is-4">
<article class="box"> <article class="box">
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; margin-bottom: 0.6rem;"> <div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; margin-bottom: 0.6rem;">
<h2 class="title is-6" style="margin: 0;">Projects</h2> <h2 class="title is-6" style="margin: 0;">Projects</h2>
<span class="tag task-ui-badge">{{ projects|length }}</span> <span class="tag task-ui-badge">{{ projects|length }}</span>
</div>
<p class="help" style="margin-bottom: 0.45rem;">Projects are created automatically from chat usage. Use this panel for manual cleanup and mapping.</p>
<div class="buttons" style="margin-bottom:0.55rem;">
{% if show_empty_projects %}
<a class="button is-small is-light" href="{% url 'tasks_hub' %}">Hide empty projects</a>
{% else %}
<a class="button is-small is-light" href="{% url 'tasks_hub' %}?show_empty=1">Show empty projects</a>
{% endif %}
</div>
<form method="post" style="margin-bottom: 0.75rem;">
{% csrf_token %}
<input type="hidden" name="action" value="project_create">
<input type="hidden" name="person" value="{{ scope.person_id }}">
<input type="hidden" name="service" value="{{ scope.service }}">
<input type="hidden" name="identifier" value="{{ scope.identifier }}">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input is-small" name="name" placeholder="New project name">
</div>
<div class="control">
<button class="button is-small is-link is-light" type="submit">Add</button>
</div>
</div> </div>
</form> <p class="help" style="margin-bottom: 0.45rem;">Projects are created automatically from chat usage. Use this panel for manual cleanup and mapping.</p>
<div class="buttons" style="margin-bottom:0.55rem;">
{% if scope.person %} {% if show_empty_projects %}
<article class="message is-light" style="margin-bottom: 0.75rem;"> <a class="button is-small is-light" href="{% url 'tasks_hub' %}">Hide empty projects</a>
<div class="message-body is-size-7"> {% else %}
Setup scope: <strong>{{ scope.person.name }}</strong> <a class="button is-small is-light" href="{% url 'tasks_hub' %}?show_empty=1">Show empty projects</a>
{% if scope.service and scope.identifier %} {% endif %}
· {{ scope.service }} · {{ scope.identifier }} </div>
{% endif %} <form method="post" style="margin-bottom: 0.75rem;">
{% csrf_token %}
<input type="hidden" name="action" value="project_create">
<input type="hidden" name="person" value="{{ scope.person_id }}">
<input type="hidden" name="service" value="{{ scope.service }}">
<input type="hidden" name="identifier" value="{{ scope.identifier }}">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input is-small" name="name" placeholder="New project name">
</div>
<div class="control">
<button class="button is-small is-link is-light" type="submit">Add</button>
</div>
</div> </div>
</article> </form>
<div style="margin-bottom: 0.75rem;">
<label class="label is-size-7">Map Linked Identifiers To Project</label> {% if scope.person %}
<form method="get"> <article class="message is-light" style="margin-bottom: 0.75rem;">
<input type="hidden" name="person" value="{{ scope.person_id }}"> <div class="message-body is-size-7">
<input type="hidden" name="service" value="{{ scope.service }}"> Setup scope: <strong>{{ scope.person.name }}</strong>
<input type="hidden" name="identifier" value="{{ scope.identifier }}"> {% if scope.service and scope.identifier %}
<div class="field has-addons"> · {{ scope.service }} · {{ scope.identifier }}
<div class="control is-expanded"> {% endif %}
<div class="select is-small is-fullwidth"> </div>
<select name="project"> </article>
<option value="">Select project</option> <div style="margin-bottom: 0.75rem;">
{% for project in project_choices %} <label class="label is-size-7">Map Linked Identifiers To Project</label>
<option value="{{ project.id }}" {% if selected_project and selected_project.id == project.id %}selected{% endif %}>{{ project.name }}</option> <form method="get">
{% endfor %} <input type="hidden" name="person" value="{{ scope.person_id }}">
</select> <input type="hidden" name="service" value="{{ scope.service }}">
<input type="hidden" name="identifier" value="{{ scope.identifier }}">
<div class="field has-addons">
<div class="control is-expanded">
<div class="select is-small is-fullwidth">
<select name="project">
<option value="">Select project</option>
{% for project in project_choices %}
<option value="{{ project.id }}" {% if selected_project and selected_project.id == project.id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<button class="button is-small is-light" type="submit">Select</button>
</div> </div>
</div> </div>
<div class="control"> </form>
<button class="button is-small is-light" type="submit">Select</button> </div>
</div> <table class="table is-fullwidth is-striped is-size-7" style="margin-bottom:0.9rem;">
</div> <thead><tr><th>Identifier</th><th>Service</th><th></th></tr></thead>
</form> <tbody>
</div> {% for row in person_identifier_rows %}
<table class="table is-fullwidth is-striped is-size-7" style="margin-bottom:0.9rem;"> <tr>
<thead><tr><th>Identifier</th><th>Service</th><th></th></tr></thead> <td><code>{{ row.identifier }}</code></td>
<tbody> <td>{{ row.service }}</td>
{% for row in person_identifier_rows %} <td class="has-text-right">
<tr> {% if selected_project %}
<td><code>{{ row.identifier }}</code></td> {% if row.mapped %}
<td>{{ row.service }}</td> <span class="tag task-ui-badge">Linked</span>
<td class="has-text-right"> {% else %}
{% if selected_project %} <form method="post">
{% if row.mapped %} {% csrf_token %}
<span class="tag task-ui-badge">Linked</span> <input type="hidden" name="action" value="project_map_identifier">
<input type="hidden" name="project_id" value="{{ selected_project.id }}">
<input type="hidden" name="person_identifier_id" value="{{ row.id }}">
<input type="hidden" name="person" value="{{ scope.person_id }}">
<input type="hidden" name="service" value="{{ scope.service }}">
<input type="hidden" name="identifier" value="{{ scope.identifier }}">
<button class="button is-small is-light" type="submit">Link</button>
</form>
{% endif %}
{% else %} {% else %}
<form method="post"> <span class="has-text-grey">Select project</span>
{% csrf_token %}
<input type="hidden" name="action" value="project_map_identifier">
<input type="hidden" name="project_id" value="{{ selected_project.id }}">
<input type="hidden" name="person_identifier_id" value="{{ row.id }}">
<input type="hidden" name="person" value="{{ scope.person_id }}">
<input type="hidden" name="service" value="{{ scope.service }}">
<input type="hidden" name="identifier" value="{{ scope.identifier }}">
<button class="button is-small is-light" type="submit">Link</button>
</form>
{% endif %} {% endif %}
{% else %} </td>
<span class="has-text-grey">Select project</span> </tr>
{% empty %}
<tr><td colspan="3">No linked identifiers for this person yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="help" style="margin-bottom: 0.75rem;">
Open this page from Compose to map a persons linked identifiers in one click.
</p>
{% endif %}
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Project</th><th>Stats</th><th></th></tr></thead>
<tbody>
{% for project in projects %}
<tr>
<td>
<a href="{% url 'tasks_project' project_id=project.id %}">{{ project.name }}</a>
</td>
<td>
<span class="tag task-ui-badge">{{ project.task_count }} task{{ project.task_count|pluralize }}</span>
<span class="tag task-ui-badge">{{ project.epic_count }} epic{{ project.epic_count|pluralize }}</span>
</td>
<td class="has-text-right">
<form method="post" onsubmit="const v=prompt('Type {{ project.name|escapejs }} to confirm delete'); if(v===null){return false;} this.confirm_name.value=v; return true;">
{% csrf_token %}
<input type="hidden" name="action" value="project_delete">
<input type="hidden" name="project_id" value="{{ project.id }}">
<input type="hidden" name="confirm_name" value="">
<button class="button is-small is-danger is-light" type="submit">Delete</button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="3">No projects yet.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div>
<div class="column">
<article class="box">
<h2 class="title is-6">Recent Derived Tasks</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Ref</th><th>Title</th><th>Created By</th><th>Project</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
{% for row in tasks %}
<tr>
<td>#{{ row.reference_code }}</td>
<td>{{ row.title }}</td>
<td>
{{ row.creator_label|default:"Unknown" }}
{% if row.creator_identifier %}
<div class="has-text-grey"><code>{{ row.creator_identifier }}</code></div>
{% endif %}
</td>
<td>{{ row.project.name }}{% if row.epic %} / {{ row.epic.name }}{% endif %}</td>
<td>{{ row.status_snapshot }}</td>
<td>
<a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a>
{% if enabled_providers|length == 1 %}
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="task_id" value="{{ row.id }}">
<input type="hidden" name="next" value="{% url 'tasks_hub' %}">
<input type="hidden" name="provider" value="{{ enabled_providers.0 }}">
<button class="button is-small is-link is-light" type="submit">
Send to {% if enabled_providers.0 == "claude_cli" %}Claude{% else %}Codex{% endif %}
</button>
</form>
{% elif enabled_providers %}
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="task_id" value="{{ row.id }}">
<input type="hidden" name="next" value="{% url 'tasks_hub' %}">
<div class="field has-addons" style="display:inline-flex;">
<div class="control">
<div class="select is-small">
<select name="provider">
{% for p in enabled_providers %}
<option value="{{ p }}">{% if p == "claude_cli" %}Claude{% else %}Codex{% endif %}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<button class="button is-small is-link is-light" type="submit">Send</button>
</div>
</div>
</form>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="3">No linked identifiers for this person yet.</td></tr> <tr><td colspan="6">No derived tasks yet.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %} </article>
<p class="help" style="margin-bottom: 0.75rem;"> </div>
Open this page from Compose to map a persons linked identifiers in one click.
</p>
{% endif %}
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Project</th><th>Stats</th><th></th></tr></thead>
<tbody>
{% for project in projects %}
<tr>
<td>
<a href="{% url 'tasks_project' project_id=project.id %}">{{ project.name }}</a>
</td>
<td>
<span class="tag task-ui-badge">{{ project.task_count }} task{{ project.task_count|pluralize }}</span>
<span class="tag task-ui-badge">{{ project.epic_count }} epic{{ project.epic_count|pluralize }}</span>
</td>
<td class="has-text-right">
<form method="post" onsubmit="const v=prompt('Type {{ project.name|escapejs }} to confirm delete'); if(v===null){return false;} this.confirm_name.value=v; return true;">
{% csrf_token %}
<input type="hidden" name="action" value="project_delete">
<input type="hidden" name="project_id" value="{{ project.id }}">
<input type="hidden" name="confirm_name" value="">
<button class="button is-small is-danger is-light" type="submit">Delete</button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="3">No projects yet.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div>
<div class="column">
<article class="box">
<h2 class="title is-6">Recent Derived Tasks</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Ref</th><th>Title</th><th>Created By</th><th>Project</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
{% for row in tasks %}
<tr>
<td>#{{ row.reference_code }}</td>
<td>{{ row.title }}</td>
<td>
{{ row.creator_label|default:"Unknown" }}
{% if row.creator_identifier %}
<div class="has-text-grey"><code>{{ row.creator_identifier }}</code></div>
{% endif %}
</td>
<td>{{ row.project.name }}{% if row.epic %} / {{ row.epic.name }}{% endif %}</td>
<td>{{ row.status_snapshot }}</td>
<td>
<a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a>
{% if enabled_providers|length == 1 %}
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="task_id" value="{{ row.id }}">
<input type="hidden" name="next" value="{% url 'tasks_hub' %}">
<input type="hidden" name="provider" value="{{ enabled_providers.0 }}">
<button class="button is-small is-link is-light" type="submit">
Send to {% if enabled_providers.0 == "claude_cli" %}Claude{% else %}Codex{% endif %}
</button>
</form>
{% elif enabled_providers %}
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="task_id" value="{{ row.id }}">
<input type="hidden" name="next" value="{% url 'tasks_hub' %}">
<div class="field has-addons" style="display:inline-flex;">
<div class="control">
<div class="select is-small">
<select name="provider">
{% for p in enabled_providers %}
<option value="{{ p }}">{% if p == "claude_cli" %}Claude{% else %}Codex{% endif %}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<button class="button is-small is-link is-light" type="submit">Send</button>
</div>
</div>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="6">No derived tasks yet.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div> </div>
</div> </div>
</div> </section>
</section>
{% endblock %} {% endblock %}

View File

@@ -1,97 +1,97 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<h1 class="title is-4">Project: {{ project.name }}</h1> <h1 class="title is-4">Project: {{ project.name }}</h1>
<div class="buttons" style="margin-bottom: 0.75rem;"> <div class="buttons" style="margin-bottom: 0.75rem;">
<a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a> <a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a>
<form method="post" onsubmit="const v=prompt('Type {{ project.name|escapejs }} to confirm delete'); if(v===null){return false;} this.confirm_name.value=v; return true;"> <form method="post" onsubmit="const v=prompt('Type {{ project.name|escapejs }} to confirm delete'); if(v===null){return false;} this.confirm_name.value=v; return true;">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="project_delete"> <input type="hidden" name="action" value="project_delete">
<input type="hidden" name="confirm_name" value=""> <input type="hidden" name="confirm_name" value="">
<button class="button is-small is-danger is-light" type="submit">Delete Project</button> <button class="button is-small is-danger is-light" type="submit">Delete Project</button>
</form> </form>
</div>
<article class="box">
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; margin-bottom: 0.6rem;">
<h2 class="title is-6" style="margin: 0;">Epics</h2>
<span class="tag task-ui-badge">{{ epics|length }}</span>
</div> </div>
<form method="post" style="margin-bottom: 0.75rem;">
{% csrf_token %}
<input type="hidden" name="action" value="epic_create">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input is-small" name="name" placeholder="New epic name">
</div>
<div class="control">
<button class="button is-small is-link is-light" type="submit">Add Epic</button>
</div>
</div>
</form>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Epic</th><th>Tasks</th><th></th></tr></thead>
<tbody>
{% for epic in epics %}
<tr>
<td><a href="{% url 'tasks_epic' epic_id=epic.id %}">{{ epic.name }}</a></td>
<td><span class="tag task-ui-badge">{{ epic.task_count }}</span></td>
<td class="has-text-right">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="epic_delete">
<input type="hidden" name="epic_id" value="{{ epic.id }}">
<button class="button is-small is-danger is-light" type="submit">Delete</button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="3">No epics.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box"> <article class="box">
<h2 class="title is-6">Tasks</h2> <div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; margin-bottom: 0.6rem;">
<table class="table is-fullwidth is-striped is-size-7"> <h2 class="title is-6" style="margin: 0;">Epics</h2>
<thead><tr><th>Ref</th><th>Title</th><th>Created By</th><th>Epic</th><th></th></tr></thead> <span class="tag task-ui-badge">{{ epics|length }}</span>
<tbody> </div>
{% for row in tasks %} <form method="post" style="margin-bottom: 0.75rem;">
<tr> {% csrf_token %}
<td>#{{ row.reference_code }}</td> <input type="hidden" name="action" value="epic_create">
<td>{{ row.title }}</td> <div class="field has-addons">
<td> <div class="control is-expanded">
{{ row.creator_label|default:"Unknown" }} <input class="input is-small" name="name" placeholder="New epic name">
{% if row.creator_compose_href %} </div>
<div><a class="is-size-7" href="{{ row.creator_compose_href }}">Compose</a></div> <div class="control">
{% endif %} <button class="button is-small is-link is-light" type="submit">Add Epic</button>
</td> </div>
<td> </div>
<form method="post" class="is-flex" style="gap: 0.35rem; align-items: center;"> </form>
{% csrf_token %} <table class="table is-fullwidth is-striped is-size-7">
<input type="hidden" name="action" value="task_set_epic"> <thead><tr><th>Epic</th><th>Tasks</th><th></th></tr></thead>
<input type="hidden" name="task_id" value="{{ row.id }}"> <tbody>
<div class="select is-small"> {% for epic in epics %}
<select name="epic_id"> <tr>
<option value="">No epic</option> <td><a href="{% url 'tasks_epic' epic_id=epic.id %}">{{ epic.name }}</a></td>
{% for epic in epics %} <td><span class="tag task-ui-badge">{{ epic.task_count }}</span></td>
<option value="{{ epic.id }}" {% if row.epic_id == epic.id %}selected{% endif %}>{{ epic.name }}</option> <td class="has-text-right">
{% endfor %} <form method="post">
</select> {% csrf_token %}
</div> <input type="hidden" name="action" value="epic_delete">
<button class="button is-small is-light" type="submit">Set</button> <input type="hidden" name="epic_id" value="{{ epic.id }}">
</form> <button class="button is-small is-danger is-light" type="submit">Delete</button>
</td> </form>
<td><a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a></td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="5">No tasks.</td></tr> <tr><td colspan="3">No epics.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</article> </article>
</div>
</section> <article class="box">
<h2 class="title is-6">Tasks</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Ref</th><th>Title</th><th>Created By</th><th>Epic</th><th></th></tr></thead>
<tbody>
{% for row in tasks %}
<tr>
<td>#{{ row.reference_code }}</td>
<td>{{ row.title }}</td>
<td>
{{ row.creator_label|default:"Unknown" }}
{% if row.creator_compose_href %}
<div><a class="is-size-7" href="{{ row.creator_compose_href }}">Compose</a></div>
{% endif %}
</td>
<td>
<form method="post" class="is-flex" style="gap: 0.35rem; align-items: center;">
{% csrf_token %}
<input type="hidden" name="action" value="task_set_epic">
<input type="hidden" name="task_id" value="{{ row.id }}">
<div class="select is-small">
<select name="epic_id">
<option value="">No epic</option>
{% for epic in epics %}
<option value="{{ epic.id }}" {% if row.epic_id == epic.id %}selected{% endif %}>{{ epic.name }}</option>
{% endfor %}
</select>
</div>
<button class="button is-small is-light" type="submit">Set</button>
</form>
</td>
<td><a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a></td>
</tr>
{% empty %}
<tr><td colspan="5">No tasks.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div>
</section>
{% endblock %} {% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -129,7 +129,7 @@
class="button is-small is-info is-light" class="button is-small is-info is-light"
onclick="giaWorkspaceQueueSelectedDraft('{{ person.id }}'); return false;"> onclick="giaWorkspaceQueueSelectedDraft('{{ person.id }}'); return false;">
<span class="icon is-small"><i class="fa-solid fa-inbox-in"></i></span> <span class="icon is-small"><i class="fa-solid fa-inbox-in"></i></span>
<span>Add To Queue</span> <span>Queue For Approval</span>
</button> </button>
</div> </div>
</div> </div>
@@ -399,8 +399,8 @@
return String(value || "") return String(value || "")
.split(",") .split(",")
.map(function (item) { .map(function (item) {
return item.trim(); return item.trim();
}) })
.filter(Boolean); .filter(Boolean);
}; };

View File

@@ -475,7 +475,7 @@
</button> </button>
<button type="submit" class="button is-info is-light" onclick="giaEngageSetAction('{{ person.id }}', 'queue');"> <button type="submit" class="button is-info is-light" onclick="giaEngageSetAction('{{ person.id }}', 'queue');">
<span class="icon is-small"><i class="fa-solid fa-inbox-in"></i></span> <span class="icon is-small"><i class="fa-solid fa-inbox-in"></i></span>
<span>Add To Queue</span> <span>Queue For Approval</span>
</button> </button>
</div> </div>
</form> </form>
@@ -867,35 +867,35 @@
}; };
defineGlobal("giaMitigationToggleEdit", function(button) { defineGlobal("giaMitigationToggleEdit", function(button) {
const form = button && button.closest ? button.closest("form") : null; const form = button && button.closest ? button.closest("form") : null;
if (!form) return; if (!form) return;
const editing = button.dataset.editState === "edit"; const editing = button.dataset.editState === "edit";
if (!editing) { if (!editing) {
form.querySelectorAll('[data-editable="1"]').forEach(function(field) { field.removeAttribute("readonly"); }); form.querySelectorAll('[data-editable="1"]').forEach(function(field) { field.removeAttribute("readonly"); });
form.querySelectorAll('[data-editable-toggle="1"]').forEach(function(field) { field.removeAttribute("disabled"); }); form.querySelectorAll('[data-editable-toggle="1"]').forEach(function(field) { field.removeAttribute("disabled"); });
const card = form.closest(".mitigation-artifact-card"); const card = form.closest(".mitigation-artifact-card");
if (card) card.classList.add("is-editing"); if (card) card.classList.add("is-editing");
button.dataset.editState = "edit"; button.dataset.editState = "edit";
button.classList.remove("is-light"); button.classList.remove("is-light");
button.title = "Save"; button.title = "Save";
button.innerHTML = '<span class="icon is-small"><i class="fa-solid fa-check"></i></span>'; button.innerHTML = '<span class="icon is-small"><i class="fa-solid fa-check"></i></span>';
resizeEditableTextareas(form); resizeEditableTextareas(form);
return; return;
} }
form.requestSubmit(); form.requestSubmit();
}); });
defineGlobal("giaEngageSetAction", function(pid, action) { defineGlobal("giaEngageSetAction", function(pid, action) {
const actionInput = document.getElementById("engage-action-input-" + pid); const actionInput = document.getElementById("engage-action-input-" + pid);
if (actionInput) actionInput.value = action; if (actionInput) actionInput.value = action;
if (action === "send") window.giaEngageSyncSendOverride(pid); if (action === "send") window.giaEngageSyncSendOverride(pid);
}); });
defineGlobal("giaEngageAutoPreview", function(pid) { defineGlobal("giaEngageAutoPreview", function(pid) {
const form = document.getElementById("engage-form-" + pid); const form = document.getElementById("engage-form-" + pid);
if (!form) return; if (!form) return;
window.giaEngageSetAction(pid, "preview"); window.giaEngageSetAction(pid, "preview");
form.requestSubmit(); form.requestSubmit();
}); });
window.giaEngageSetTarget = function(pid, targetId) { window.giaEngageSetTarget = function(pid, targetId) {
@@ -908,14 +908,14 @@
}; };
defineGlobal("giaEngageSelect", function(pid, kind, value, node) { defineGlobal("giaEngageSelect", function(pid, kind, value, node) {
const inputId = kind === "share" ? ("engage-share-input-" + pid) : (kind === "framing" ? ("engage-framing-input-" + pid) : ""); const inputId = kind === "share" ? ("engage-share-input-" + pid) : (kind === "framing" ? ("engage-framing-input-" + pid) : "");
const input = inputId ? document.getElementById(inputId) : null; const input = inputId ? document.getElementById(inputId) : null;
if (input) input.value = value; if (input) input.value = value;
const li = node && node.closest ? node.closest("li") : null; const li = node && node.closest ? node.closest("li") : null;
if (!li || !li.parentElement) return; if (!li || !li.parentElement) return;
Array.from(li.parentElement.children).forEach(function(child) { child.classList.remove("is-active"); }); Array.from(li.parentElement.children).forEach(function(child) { child.classList.remove("is-active"); });
li.classList.add("is-active"); li.classList.add("is-active");
window.giaEngageAutoPreview(pid); window.giaEngageAutoPreview(pid);
}); });
window.giaMitigationShowTab(personId, "{{ active_tab|default:'plan_board' }}"); window.giaMitigationShowTab(personId, "{{ active_tab|default:'plan_board' }}");

View File

@@ -536,8 +536,8 @@
showOperationPane(operation); showOperationPane(operation);
const activeTab = tabKey || ( const activeTab = tabKey || (
operation === "artifacts" operation === "artifacts"
? ((window.giaWorkspaceState[personId] || {}).currentMitigationTab || "plan_board") ? ((window.giaWorkspaceState[personId] || {}).currentMitigationTab || "plan_board")
: operation : operation
); );
setTopCapsuleActive(activeTab); setTopCapsuleActive(activeTab);
const hydrated = hydrateCachedIfAvailable(operation); const hydrated = hydrateCachedIfAvailable(operation);
@@ -573,8 +573,8 @@
const currentState = window.giaWorkspaceState[personId] || {}; const currentState = window.giaWorkspaceState[personId] || {};
const targetTabKey = currentState.pendingTabKey || ( const targetTabKey = currentState.pendingTabKey || (
operation === "artifacts" operation === "artifacts"
? (currentState.currentMitigationTab || "plan_board") ? (currentState.currentMitigationTab || "plan_board")
: operation : operation
); );
if (!forceRefresh && currentState.current === operation && pane.dataset.loaded === "1") { if (!forceRefresh && currentState.current === operation && pane.dataset.loaded === "1") {
window.giaWorkspaceShowTab(personId, operation, targetTabKey); window.giaWorkspaceShowTab(personId, operation, targetTabKey);
@@ -622,38 +622,38 @@
fetch(url, { method: "GET" }) fetch(url, { method: "GET" })
.then(function(resp) { return resp.text(); }) .then(function(resp) { return resp.text(); })
.then(function(html) { .then(function(html) {
pane.innerHTML = html; pane.innerHTML = html;
pane.dataset.loaded = "1"; pane.dataset.loaded = "1";
executeInlineScripts(pane); executeInlineScripts(pane);
pane.classList.remove("ai-animate-in"); pane.classList.remove("ai-animate-in");
void pane.offsetWidth; void pane.offsetWidth;
pane.classList.add("ai-animate-in"); pane.classList.add("ai-animate-in");
if (cacheAllowed) { if (cacheAllowed) {
window.giaWorkspaceCache[key] = { window.giaWorkspaceCache[key] = {
html: html, html: html,
ts: Date.now(), ts: Date.now(),
}; };
persistCache(); persistCache();
setCachedIndicator(true, window.giaWorkspaceCache[key].ts); setCachedIndicator(true, window.giaWorkspaceCache[key].ts);
} else { } else {
setCachedIndicator(false, null); setCachedIndicator(false, null);
} }
if (window.htmx) { if (window.htmx) {
window.htmx.process(pane); window.htmx.process(pane);
} }
if (operation === "draft_reply" && typeof window.giaWorkspaceUseDraft === "function") { if (operation === "draft_reply" && typeof window.giaWorkspaceUseDraft === "function") {
window.giaWorkspaceUseDraft(personId, operation, 0); window.giaWorkspaceUseDraft(personId, operation, 0);
} }
if (operation === "artifacts") { if (operation === "artifacts") {
applyMitigationTabSelection(); applyMitigationTabSelection();
} }
if (window.giaWorkspaceState[personId]) { if (window.giaWorkspaceState[personId]) {
window.giaWorkspaceState[personId].pendingTabKey = ""; window.giaWorkspaceState[personId].pendingTabKey = "";
} }
}) })
.catch(function() { .catch(function() {
pane.innerHTML = '<div class="notification is-danger is-light ai-animate-in">Failed to load AI response.</div>'; pane.innerHTML = '<div class="notification is-danger is-light ai-animate-in">Failed to load AI response.</div>';
}); });
}; };
window.giaWorkspaceRefresh = function(pid) { window.giaWorkspaceRefresh = function(pid) {
@@ -663,8 +663,8 @@
const state = window.giaWorkspaceState[personId] || {}; const state = window.giaWorkspaceState[personId] || {};
const currentTab = state.currentTab || ( const currentTab = state.currentTab || (
state.current === "artifacts" state.current === "artifacts"
? (state.currentMitigationTab || "plan_board") ? (state.currentMitigationTab || "plan_board")
: (state.current || "plan_board") : (state.current || "plan_board")
); );
window.giaWorkspaceOpenTab(personId, currentTab, true); window.giaWorkspaceOpenTab(personId, currentTab, true);
}; };
@@ -754,15 +754,15 @@
}) })
.then(function(resp) { return resp.text(); }) .then(function(resp) { return resp.text(); })
.then(function(html) { .then(function(html) {
if (statusHost) { if (statusHost) {
statusHost.innerHTML = html; statusHost.innerHTML = html;
} }
}) })
.catch(function() { .catch(function() {
if (statusHost) { if (statusHost) {
statusHost.innerHTML = '<div class="notification is-danger is-light" style="padding: 0.45rem 0.6rem;">Failed to queue draft.</div>'; statusHost.innerHTML = '<div class="notification is-danger is-light" style="padding: 0.45rem 0.6rem;">Failed to queue draft.</div>';
} }
}); });
}; };
function getSelectedTargetId() { function getSelectedTargetId() {
@@ -841,92 +841,92 @@
}; };
defineGlobal("giaMitigationShowTab", function(pid, tabName) { defineGlobal("giaMitigationShowTab", function(pid, tabName) {
const names = ["plan_board", "corrections", "engage", "fundamentals", "ask_ai"]; const names = ["plan_board", "corrections", "engage", "fundamentals", "ask_ai"];
names.forEach(function(name) { names.forEach(function(name) {
const pane = document.getElementById("mitigation-tab-" + pid + "-" + name); const pane = document.getElementById("mitigation-tab-" + pid + "-" + name);
const tab = document.getElementById("mitigation-tab-btn-" + pid + "-" + name); const tab = document.getElementById("mitigation-tab-btn-" + pid + "-" + name);
if (!pane) { if (!pane) {
return;
}
const active = (name === tabName);
pane.style.display = active ? "block" : "none";
if (tab) {
tab.classList.toggle("is-active", active);
}
});
const shell = document.getElementById("mitigation-shell-" + pid);
if (!shell) {
return; return;
} }
shell.querySelectorAll('input[name="active_tab"]').forEach(function(input) { const active = (name === tabName);
input.value = tabName; pane.style.display = active ? "block" : "none";
}); if (tab) {
tab.classList.toggle("is-active", active);
}
}); });
const shell = document.getElementById("mitigation-shell-" + pid);
if (!shell) {
return;
}
shell.querySelectorAll('input[name="active_tab"]').forEach(function(input) {
input.value = tabName;
});
});
defineGlobal("giaMitigationToggleEdit", function(button) { defineGlobal("giaMitigationToggleEdit", function(button) {
const form = button ? button.closest("form") : null; const form = button ? button.closest("form") : null;
if (!form) { if (!form) {
return; return;
}
const card = form.closest(".mitigation-artifact-card");
const editing = button.dataset.editState === "edit";
const fields = form.querySelectorAll('[data-editable="1"]');
const toggles = form.querySelectorAll('[data-editable-toggle="1"]');
if (!editing) {
fields.forEach(function(field) {
field.removeAttribute("readonly");
});
toggles.forEach(function(field) {
field.removeAttribute("disabled");
});
if (card) {
card.classList.add("is-editing");
} }
const card = form.closest(".mitigation-artifact-card"); button.dataset.editState = "edit";
const editing = button.dataset.editState === "edit"; button.classList.remove("is-light");
const fields = form.querySelectorAll('[data-editable="1"]'); button.title = "Save";
const toggles = form.querySelectorAll('[data-editable-toggle="1"]'); button.innerHTML = '<span class="icon is-small"><i class="fa-solid fa-check"></i></span>';
if (!editing) { } else {
fields.forEach(function(field) { form.requestSubmit();
field.removeAttribute("readonly"); }
}); });
toggles.forEach(function(field) {
field.removeAttribute("disabled");
});
if (card) {
card.classList.add("is-editing");
}
button.dataset.editState = "edit";
button.classList.remove("is-light");
button.title = "Save";
button.innerHTML = '<span class="icon is-small"><i class="fa-solid fa-check"></i></span>';
} else {
form.requestSubmit();
}
});
defineGlobal("giaEngageSetAction", function(pid, action) { defineGlobal("giaEngageSetAction", function(pid, action) {
const actionInput = document.getElementById("engage-action-input-" + pid); const actionInput = document.getElementById("engage-action-input-" + pid);
if (actionInput) { if (actionInput) {
actionInput.value = action; actionInput.value = action;
} }
}); });
defineGlobal("giaEngageAutoPreview", function(pid) { defineGlobal("giaEngageAutoPreview", function(pid) {
const form = document.getElementById("engage-form-" + pid); const form = document.getElementById("engage-form-" + pid);
if (!form) { if (!form) {
return; return;
} }
window.giaEngageSetAction(pid, "preview"); window.giaEngageSetAction(pid, "preview");
form.requestSubmit(); form.requestSubmit();
}); });
defineGlobal("giaEngageSelect", function(pid, kind, value, node) { defineGlobal("giaEngageSelect", function(pid, kind, value, node) {
let inputId = ""; let inputId = "";
if (kind === "share") { if (kind === "share") {
inputId = "engage-share-input-" + pid; inputId = "engage-share-input-" + pid;
} else if (kind === "framing") { } else if (kind === "framing") {
inputId = "engage-framing-input-" + pid; inputId = "engage-framing-input-" + pid;
} }
const input = inputId ? document.getElementById(inputId) : null; const input = inputId ? document.getElementById(inputId) : null;
if (input) { if (input) {
input.value = value; input.value = value;
} }
const li = node && node.closest ? node.closest("li") : null; const li = node && node.closest ? node.closest("li") : null;
if (li && li.parentElement) { if (li && li.parentElement) {
Array.from(li.parentElement.children).forEach(function(child) { Array.from(li.parentElement.children).forEach(function(child) {
child.classList.remove("is-active"); child.classList.remove("is-active");
}); });
li.classList.add("is-active"); li.classList.add("is-active");
} }
window.giaEngageAutoPreview(pid); window.giaEngageAutoPreview(pid);
}); });
window.giaWorkspaceOpenTab(personId, "plan_board", false); window.giaWorkspaceOpenTab(personId, "plan_board", false);
syncTargetInputs(); syncTargetInputs();

View File

@@ -2323,8 +2323,8 @@
glanceState && glanceState.gap ? glanceState.gap.lag_ms : 0 glanceState && glanceState.gap ? glanceState.gap.lag_ms : 0
); );
const baselineMs = baselineFromGapMs > 0 const baselineMs = baselineFromGapMs > 0
? baselineFromGapMs ? baselineFromGapMs
: toInt(snapshot.counterpartBaselineMs); : toInt(snapshot.counterpartBaselineMs);
if (!baselineMs) { if (!baselineMs) {
replyTimingState = { replyTimingState = {
sinceLabel: sinceLabel, sinceLabel: sinceLabel,
@@ -2758,13 +2758,13 @@
const safe = Array.isArray(items) ? items.slice(0, 3) : []; const safe = Array.isArray(items) ? items.slice(0, 3) : [];
const ordered = safe const ordered = safe
.filter(function (item) { .filter(function (item) {
return /^delay$/i.test(String(item && item.label ? item.label : "")); return /^delay$/i.test(String(item && item.label ? item.label : ""));
})
.concat(
safe.filter(function (item) {
return !/^delay$/i.test(String(item && item.label ? item.label : ""));
}) })
); .concat(
safe.filter(function (item) {
return !/^delay$/i.test(String(item && item.label ? item.label : ""));
})
);
glanceNode.innerHTML = ""; glanceNode.innerHTML = "";
ordered.forEach(function (item) { ordered.forEach(function (item) {
const url = String(item.url || "").trim(); const url = String(item.url || "").trim();
@@ -3326,11 +3326,11 @@
bubble.appendChild(blockGap); bubble.appendChild(blockGap);
} }
const imageCandidatesFromPayload = Array.isArray(msg.image_urls) && msg.image_urls.length const imageCandidatesFromPayload = Array.isArray(msg.image_urls) && msg.image_urls.length
? msg.image_urls ? msg.image_urls
: (msg.image_url ? [msg.image_url] : []); : (msg.image_url ? [msg.image_url] : []);
const imageCandidates = imageCandidatesFromPayload.length const imageCandidates = imageCandidatesFromPayload.length
? imageCandidatesFromPayload ? imageCandidatesFromPayload
: extractUrlCandidates(msg.text || msg.display_text || ""); : extractUrlCandidates(msg.text || msg.display_text || "");
appendImageCandidates(bubble, imageCandidates); appendImageCandidates(bubble, imageCandidates);
if (!msg.hide_text) { if (!msg.hide_text) {
@@ -3376,8 +3376,8 @@
const deletedFlag = document.createElement("span"); const deletedFlag = document.createElement("span");
deletedFlag.className = "compose-msg-flag is-deleted"; deletedFlag.className = "compose-msg-flag is-deleted";
deletedFlag.title = "Deleted" deletedFlag.title = "Deleted"
+ (msg.deleted_display ? (" at " + String(msg.deleted_display)) : "") + (msg.deleted_display ? (" at " + String(msg.deleted_display)) : "")
+ (msg.deleted_actor ? (" by " + String(msg.deleted_actor)) : ""); + (msg.deleted_actor ? (" by " + String(msg.deleted_actor)) : "");
deletedFlag.textContent = "deleted"; deletedFlag.textContent = "deleted";
meta.appendChild(deletedFlag); meta.appendChild(deletedFlag);
} }
@@ -5184,7 +5184,7 @@
} catch (err) { } catch (err) {
setCardLoading(card, false); setCardLoading(card, false);
card.querySelector(".compose-ai-content").textContent = card.querySelector(".compose-ai-content").textContent =
"Failed to load quick insights."; "Failed to load quick insights.";
} }
}; };
@@ -5203,8 +5203,8 @@
const customText = card.querySelector(".engage-custom-text"); const customText = card.querySelector(".engage-custom-text");
const selectedSource = ( const selectedSource = (
preferredSource !== undefined preferredSource !== undefined
? preferredSource ? preferredSource
: (sourceSelect ? sourceSelect.value : "") : (sourceSelect ? sourceSelect.value : "")
); );
const customValue = customText ? String(customText.value || "").trim() : ""; const customValue = customText ? String(customText.value || "").trim() : "";
const showCustom = selectedSource === "custom"; const showCustom = selectedSource === "custom";
@@ -5382,8 +5382,8 @@
const selectedPerson = selected.dataset.person || thread.dataset.person || ""; const selectedPerson = selected.dataset.person || thread.dataset.person || "";
const selectedPageUrl = ( const selectedPageUrl = (
renderMode === "page" renderMode === "page"
? selected.dataset.pageUrl ? selected.dataset.pageUrl
: selected.dataset.widgetUrl : selected.dataset.widgetUrl
) || ""; ) || "";
switchThreadContext( switchThreadContext(
selectedService, selectedService,
@@ -5412,8 +5412,8 @@
const selectedPerson = selected.dataset.person || ""; const selectedPerson = selected.dataset.person || "";
let selectedPageUrl = ( let selectedPageUrl = (
renderMode === "page" renderMode === "page"
? selected.dataset[servicePageUrlKey] ? selected.dataset[servicePageUrlKey]
: selected.dataset[serviceWidgetUrlKey] : selected.dataset[serviceWidgetUrlKey]
) || ""; ) || "";
if (!selectedIdentifier) { if (!selectedIdentifier) {
selectedService = selected.dataset.service || selectedService; selectedService = selected.dataset.service || selectedService;
@@ -5422,8 +5422,8 @@
if (!selectedPageUrl) { if (!selectedPageUrl) {
selectedPageUrl = ( selectedPageUrl = (
renderMode === "page" renderMode === "page"
? selected.dataset.pageUrl ? selected.dataset.pageUrl
: selected.dataset.widgetUrl : selected.dataset.widgetUrl
) || ""; ) || "";
} }
switchThreadContext( switchThreadContext(

View File

@@ -3,10 +3,10 @@
<div class="column is-12-mobile is-12-tablet"> <div class="column is-12-mobile is-12-tablet">
<div <div
style=" style="
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
padding: 0.5rem 0.25rem; padding: 0.5rem 0.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.12); border-bottom: 1px solid rgba(0, 0, 0, 0.12);
"> ">
<p class="is-size-7 has-text-weight-semibold">Manual Workspace</p> <p class="is-size-7 has-text-weight-semibold">Manual Workspace</p>
<h3 class="title is-6" style="margin-bottom: 0.5rem;">Choose A Contact</h3> <h3 class="title is-6" style="margin-bottom: 0.5rem;">Choose A Contact</h3>
<p class="is-size-7"> <p class="is-size-7">
@@ -17,10 +17,10 @@
<form <form
id="compose-workspace-window-form" id="compose-workspace-window-form"
style=" style="
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
padding: 0.5rem 0.25rem; padding: 0.5rem 0.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.12); border-bottom: 1px solid rgba(0, 0, 0, 0.12);
"> ">
<label class="label is-small" for="compose-workspace-limit">Window</label> <label class="label is-small" for="compose-workspace-limit">Window</label>
<div class="select is-fullwidth is-small"> <div class="select is-fullwidth is-small">
<select id="compose-workspace-limit" name="limit"> <select id="compose-workspace-limit" name="limit">
@@ -43,12 +43,12 @@
<button <button
class="button is-fullwidth" class="button is-fullwidth"
style=" style="
border-radius: 8px; border-radius: 8px;
border: 0; border: 0;
background: transparent; background: transparent;
box-shadow: none; box-shadow: none;
padding: 0; padding: 0;
" "
hx-get="{{ row.compose_widget_url }}" hx-get="{{ row.compose_widget_url }}"
hx-include="#compose-workspace-window-form" hx-include="#compose-workspace-window-form"
hx-target="#widgets-here" hx-target="#widgets-here"
@@ -56,42 +56,42 @@
<span <span
class="tags has-addons" class="tags has-addons"
style=" style="
display: inline-flex; display: inline-flex;
width: 100%; width: 100%;
margin: 0; margin: 0;
white-space: nowrap; white-space: nowrap;
"> ">
<span <span
class="tag is-white" class="tag is-white"
style=" style="
flex: 1; flex: 1;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.75rem; gap: 0.75rem;
padding-left: 0.7rem; padding-left: 0.7rem;
padding-right: 0.7rem; padding-right: 0.7rem;
border: 1px solid rgba(0, 0, 0, 0.2); border: 1px solid rgba(0, 0, 0, 0.2);
min-width: 0; min-width: 0;
"> ">
<span <span
style=" style="
display: inline-flex; display: inline-flex;
align-items: baseline; align-items: baseline;
gap: 0.45rem; gap: 0.45rem;
min-width: 0; min-width: 0;
"> ">
<strong>{{ row.person_name }}</strong> <strong>{{ row.person_name }}</strong>
<small class="has-text-grey">{{ row.service|title }}</small> <small class="has-text-grey">{{ row.service|title }}</small>
</span> </span>
<small <small
class="has-text-grey" class="has-text-grey"
style=" style="
min-width: 0; min-width: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
word-break: break-all; word-break: break-all;
text-align: right; text-align: right;
"> ">
{{ row.identifier }} {{ row.identifier }}
</small> </small>
</span> </span>

View File

@@ -153,14 +153,14 @@
</label> </label>
</div> </div>
</div> </div>
<div class="content is-size-7" style="margin-top: 0.2rem;"> <div class="content is-size-7" style="margin-top: 0.2rem;">
<ul> <ul>
<li><strong>Min/Max Sent.</strong>: sentiment bounds for people/contact results (-1 to 1).</li> <li><strong>Min/Max Sent.</strong>: sentiment bounds for people/contact results (-1 to 1).</li>
<li><strong>Annotate snippets</strong>: shows contextual snippets around query hits.</li> <li><strong>Annotate snippets</strong>: shows contextual snippets around query hits.</li>
<li><strong>Deduplicate</strong>: removes near-identical repeated rows.</li> <li><strong>Deduplicate</strong>: removes near-identical repeated rows.</li>
<li><strong>Reverse output</strong>: reverses final result order after sorting.</li> <li><strong>Reverse output</strong>: reverses final result order after sorting.</li>
</ul> </ul>
</div> </div>
</div> </div>
</details> </details>
</form> </form>

View File

@@ -12,7 +12,7 @@
<div class="is-flex is-justify-content-space-between is-align-items-center" style="margin-bottom: 0.75rem; gap: 0.5rem; flex-wrap: wrap;"> <div class="is-flex is-justify-content-space-between is-align-items-center" style="margin-bottom: 0.75rem; gap: 0.5rem; flex-wrap: wrap;">
<div> <div>
<h3 class="title is-6" style="margin-bottom: 0.15rem;">Outgoing Queue</h3> <h3 class="title is-6" style="margin-bottom: 0.15rem;">Approvals Queue</h3>
<p class="is-size-7">Review queued drafts and approve or reject each message.</p> <p class="is-size-7">Review queued drafts and approve or reject each message.</p>
</div> </div>
<span class="tag is-dark is-medium">{{ object_list|length }} pending</span> <span class="tag is-dark is-medium">{{ object_list|length }} pending</span>
@@ -57,7 +57,7 @@
</div> </div>
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; flex-wrap: wrap;"> <div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; flex-wrap: wrap;">
<small class="has-text-grey">Queue ID: {{ item.id }}</small> <small class="has-text-grey">Approval ID: {{ item.id }}</small>
<div class="buttons are-small" style="margin: 0;"> <div class="buttons are-small" style="margin: 0;">
<button <button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
@@ -92,7 +92,7 @@
</div> </div>
{% else %} {% else %}
<article class="box" style="padding: 0.8rem; border: 1px dashed rgba(0, 0, 0, 0.25); box-shadow: none;"> <article class="box" style="padding: 0.8rem; border: 1px dashed rgba(0, 0, 0, 0.25); box-shadow: none;">
<p class="is-size-7 has-text-grey">Queue is empty.</p> <p class="is-size-7 has-text-grey">Approvals Queue is empty.</p>
</article> </article>
{% endif %} {% endif %}
</div> </div>

View File

@@ -3,9 +3,9 @@
<div class="tabs is-boxed is-small mb-4 security-page-tabs"> <div class="tabs is-boxed is-small mb-4 security-page-tabs">
<ul> <ul>
{% for tab in settings_nav.tabs %} {% for tab in settings_nav.tabs %}
<li class="{% if tab.active %}is-active{% endif %}"> <li class="{% if tab.active %}is-active{% endif %}">
<a href="{{ tab.href }}">{{ tab.label }}</a> <a href="{{ tab.href }}">{{ tab.label }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>

View File

@@ -1,154 +1,154 @@
{% include 'mixins/partials/notify.html' %} {% include 'mixins/partials/notify.html' %}
<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"
id="{{ context_object_name }}-table" id="{{ context_object_name }}-table"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-trigger="{{ context_object_name_singular }}Event from:body" hx-trigger="{{ context_object_name_singular }}Event from:body"
hx-get="{{ list_url }}"> hx-get="{{ list_url }}">
<thead> <thead>
<th>number</th> <th>number</th>
<th>uuid</th> <th>uuid</th>
<th>account</th> <th>account</th>
<th>name</th> <th>name</th>
<th>person</th> <th>person</th>
<th>availability</th> <th>availability</th>
<th>actions</th> <th>actions</th>
</thead> </thead>
{% for item in object_list %} {% for item in object_list %}
<tr> <tr>
<td>{% if item.chat %}{{ item.chat.source_number }}{% endif %}</td> <td>{% if item.chat %}{{ item.chat.source_number }}{% endif %}</td>
<td> <td>
{% if item.chat %} {% if item.chat %}
<a <a
class="has-text-grey button nowrap-child" class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.chat.source_uuid }}');"> onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.chat.source_uuid }}');">
<span class="icon" data-tooltip="Copy to clipboard"> <span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i> <i class="fa-solid fa-copy" aria-hidden="true"></i>
</span> </span>
</a> </a>
{% endif %} {% endif %}
</td> </td>
<td>{% if item.chat %}{{ item.chat.account }}{% endif %}</td> <td>{% if item.chat %}{{ item.chat.account }}{% endif %}</td>
<td> <td>
{% if item.is_group %} {% if item.is_group %}
<span class="tag is-info is-light is-small mr-1"><i class="fa-solid fa-users"></i></span> <span class="tag is-info is-light is-small mr-1"><i class="fa-solid fa-users"></i></span>
{% endif %} {% endif %}
{% if item.chat %}{{ item.chat.source_name }}{% else %}{{ item.name }}{% endif %} {% if item.chat %}{{ item.chat.source_name }}{% else %}{{ item.name }}{% endif %}
</td> </td>
<td>{{ item.person_name|default:"-" }}</td> <td>{{ item.person_name|default:"-" }}</td>
<td> <td>
{% if item.availability_label %} {% if item.availability_label %}
<span class="tag is-light">{{ item.availability_label }}</span> <span class="tag is-light">{{ item.availability_label }}</span>
{% else %} {% else %}
- -
{% endif %} {% endif %}
</td> </td>
<td> <td>
<div class="buttons"> <div class="buttons">
{% if not item.is_group %} {% if not item.is_group %}
<button <button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete="{# url 'account_delete' type=type pk=item.id #}" hx-delete="{# url 'account_delete' type=type pk=item.id #}"
hx-trigger="click" hx-trigger="click"
hx-target="#modals-here" hx-target="#modals-here"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-confirm="Are you sure you wish to unlink {{ item.chat }}?" hx-confirm="Are you sure you wish to unlink {{ item.chat }}?"
class="button"> class="button">
<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-xmark"></i>
</span>
</span> </span>
</button> </span>
{% endif %} </button>
{% if type == 'page' %} {% endif %}
{% if item.can_compose %} {% if type == 'page' %}
<a href="{{ item.compose_page_url }}"><button {% if item.can_compose %}
class="button" <a href="{{ item.compose_page_url }}"><button
title="Manual text mode">
<span class="icon-text">
<span class="icon">
<i class="{{ item.manual_icon_class }}"></i>
</span>
</span>
</button>
</a>
{% else %}
<button class="button" disabled title="No identifier available for manual send">
<span class="icon-text">
<span class="icon">
<i class="{{ item.manual_icon_class }}"></i>
</span>
</span>
</button>
{% endif %}
{% if not item.is_group %}
<a href="{{ item.match_url }}"><button
class="button"
title="Match identifier to person">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-link"></i>
</span>
</span>
</button>
</a>
{% endif %}
<a href="{{ item.ai_url }}"><button
class="button" class="button"
title="Open AI workspace"> title="Manual text mode">
<span class="icon-text"> <span class="icon-text">
<span class="icon"> <span class="icon">
<i class="fa-solid fa-brain-circuit"></i> <i class="{{ item.manual_icon_class }}"></i>
</span> </span>
</span> </span>
</button> </button>
</a> </a>
{% else %} {% else %}
{% if item.can_compose %} <button class="button" disabled title="No identifier available for manual send">
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ item.compose_widget_url }}"
hx-trigger="click"
hx-target="#widgets-here"
hx-swap="afterend"
class="button">
<span class="icon-text">
<span class="icon">
<i class="{{ item.manual_icon_class }}"></i>
</span>
</span>
</button>
{% else %}
<button class="button" disabled title="No identifier available for manual send">
<span class="icon-text">
<span class="icon">
<i class="{{ item.manual_icon_class }}"></i>
</span>
</span>
</button>
{% endif %}
{% if not item.is_group %}
<a href="{{ item.match_url }}"><button class="button" title="Match identifier to person">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-link"></i>
</span>
</span>
</button></a>
{% endif %}
<a href="{{ item.ai_url }}"><button class="button">
<span class="icon-text"> <span class="icon-text">
<span class="icon"> <span class="icon">
<i class="fa-solid fa-brain-circuit"></i> <i class="{{ item.manual_icon_class }}"></i>
</span>
</span>
</button>
{% endif %}
{% if not item.is_group %}
<a href="{{ item.match_url }}"><button
class="button"
title="Match identifier to person">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-link"></i>
</span>
</span>
</button>
</a>
{% endif %}
<a href="{{ item.ai_url }}"><button
class="button"
title="Open AI workspace">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-brain-circuit"></i>
</span>
</span>
</button>
</a>
{% else %}
{% if item.can_compose %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ item.compose_widget_url }}"
hx-trigger="click"
hx-target="#widgets-here"
hx-swap="afterend"
class="button">
<span class="icon-text">
<span class="icon">
<i class="{{ item.manual_icon_class }}"></i>
</span>
</span>
</button>
{% else %}
<button class="button" disabled title="No identifier available for manual send">
<span class="icon-text">
<span class="icon">
<i class="{{ item.manual_icon_class }}"></i>
</span>
</span>
</button>
{% endif %}
{% if not item.is_group %}
<a href="{{ item.match_url }}"><button class="button" title="Match identifier to person">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-link"></i>
</span> </span>
</span> </span>
</button></a> </button></a>
{% endif %} {% endif %}
</div> <a href="{{ item.ai_url }}"><button class="button">
</td> <span class="icon-text">
</tr> <span class="icon">
{% endfor %} <i class="fa-solid fa-brain-circuit"></i>
</span>
</span>
</button></a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</table> </table>

View File

@@ -4,12 +4,12 @@
src="data:image/png;base64, {{ object.image_b64 }}" src="data:image/png;base64, {{ object.image_b64 }}"
alt="WhatsApp QR code" alt="WhatsApp QR code"
style=" style="
display: block; display: block;
width: 100%; width: 100%;
max-width: 420px; max-width: 420px;
height: auto; height: auto;
margin: 0 auto; margin: 0 auto;
" /> " />
{% if object.warning %} {% if object.warning %}
<p class="is-size-7" style="margin-top: 0.6rem;">{{ object.warning }}</p> <p class="is-size-7" style="margin-top: 0.6rem;">{{ object.warning }}</p>
{% endif %} {% endif %}

View File

@@ -3,12 +3,12 @@
<div class="buttons"> <div class="buttons">
{% if cancel_url %} {% if cancel_url %}
<a href="{{ cancel_url }}" <a href="{{ cancel_url }}"
class="button">{% trans "Cancel" %}</a> class="button">{% trans "Cancel" %}</a>
{% endif %} {% endif %}
{% if wizard.steps.prev %} {% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" <button name="wizard_goto_step" type="submit"
value="{{ wizard.steps.prev }}" value="{{ wizard.steps.prev }}"
class="button">{% trans "Back" %}</button> class="button">{% trans "Back" %}</button>
{% else %} {% else %}
<button disabled name="" type="button" class="button">{% trans "Back" %}</button> <button disabled name="" type="button" class="button">{% trans "Back" %}</button>
{% endif %} {% endif %}

View File

@@ -22,7 +22,7 @@
<form method="post">{% csrf_token %}{{ form }} <form method="post">{% csrf_token %}{{ form }}
<a href="{% url 'security_2fa' %}" <a href="{% url 'security_2fa' %}"
class="float-right button">{% trans "Back to Account Security" %}</a> class="float-right button">{% trans "Back to Account Security" %}</a>
<button class="button" type="submit">{% trans "Generate Tokens" %}</button> <button class="button" type="submit">{% trans "Generate Tokens" %}</button>
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -34,7 +34,7 @@
<p class="subtitle"> <p class="subtitle">
{% for other in other_devices %} {% for other in other_devices %}
<button name="challenge_device" value="{{ other.persistent_id }}" <button name="challenge_device" value="{{ other.persistent_id }}"
class="button" type="submit"> class="button" type="submit">
{{ other.generate_challenge_button_title }} {{ other.generate_challenge_button_title }}
</button> </button>
{% endfor %}</p> {% endfor %}</p>
@@ -43,7 +43,7 @@
<p class="subtitle">{% trans "As a last resort, you can use a backup token:" %}</p> <p class="subtitle">{% trans "As a last resort, you can use a backup token:" %}</p>
<p class="subtitle"> <p class="subtitle">
<button name="wizard_goto_step" type="submit" value="backup" <button name="wizard_goto_step" type="submit" value="backup"
class="button">{% trans "Use Backup Token" %}</button> class="button">{% trans "Use Backup Token" %}</button>
</p> </p>
{% endif %} {% endif %}

View File

@@ -14,7 +14,7 @@
<div class="buttons"> <div class="buttons">
<a href="javascript:history.go(-1)" <a href="javascript:history.go(-1)"
class="float-right button">{% trans "Go back" %}</a> class="float-right button">{% trans "Go back" %}</a>
<a href="{% url 'two_factor:setup' %}" class="button"> <a href="{% url 'two_factor:setup' %}" class="button">
{% trans "Enable Two-Factor Authentication" %}</a> {% trans "Enable Two-Factor Authentication" %}</a>
</div> </div>

View File

@@ -9,16 +9,16 @@
{% if not phone_methods %} {% if not phone_methods %}
<p class="subtitle"><a href="{% url 'security_2fa' %}" <p class="subtitle"><a href="{% url 'security_2fa' %}"
class="button">{% trans "Back to Account Security" %}</a></p> class="button">{% trans "Back to Account Security" %}</a></p>
{% else %} {% else %}
<p class="subtitle">{% blocktrans trimmed %}However, it might happen that you don't have access to <p class="subtitle">{% blocktrans trimmed %}However, it might happen that you don't have access to
your primary token device. To enable account recovery, add a phone your primary token device. To enable account recovery, add a phone
number.{% endblocktrans %}</p> number.{% endblocktrans %}</p>
<a href="{% url 'security_2fa' %}" <a href="{% url 'security_2fa' %}"
class="float-right button">{% trans "Back to Account Security" %}</a> class="float-right button">{% trans "Back to Account Security" %}</a>
<p class="subtitle"><a href="{% url 'two_factor:phone_create' %}" <p class="subtitle"><a href="{% url 'two_factor:phone_create' %}"
class="button">{% trans "Add Phone Number" %}</a></p> class="button">{% trans "Add Phone Number" %}</a></p>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@@ -9,6 +9,6 @@
{% csrf_token %} {% csrf_token %}
<table>{{ form }}</table> <table>{{ form }}</table>
<button class="button" <button class="button"
type="submit">{% trans "Disable" %}</button> type="submit">{% trans "Disable" %}</button>
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -22,16 +22,16 @@
<li> <li>
{{ phone.generate_challenge_button_title }} {{ phone.generate_challenge_button_title }}
<form method="post" action="{% url 'two_factor:phone_delete' phone.id %}" <form method="post" action="{% url 'two_factor:phone_delete' phone.id %}"
onsubmit="return confirm({% trans 'Are you sure?' %})"> onsubmit="return confirm({% trans 'Are you sure?' %})">
{% csrf_token %} {% csrf_token %}
<button class="button is-warning" <button class="button is-warning"
type="submit">{% trans "Unregister" %}</button> type="submit">{% trans "Unregister" %}</button>
</form> </form>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<p class="subtitle"><a href="{% url 'two_factor:phone_create' %}" <p class="subtitle"><a href="{% url 'two_factor:phone_create' %}"
class="button">{% trans "Add Phone Number" %}</a></p> class="button">{% trans "Add Phone Number" %}</a></p>
{% endif %} {% endif %}
<h2 class="title is-4">{% trans "Backup Tokens" %}</h2> <h2 class="title is-4">{% trans "Backup Tokens" %}</h2>
@@ -45,7 +45,7 @@
{% endblocktrans %} {% endblocktrans %}
</p> </p>
<p class="subtitle"><a href="{% url 'two_factor:backup_tokens' %}" <p class="subtitle"><a href="{% url 'two_factor:backup_tokens' %}"
class="button">{% trans "Show Codes" %}</a></p> class="button">{% trans "Show Codes" %}</a></p>
<h3 class="title is-5">{% trans "Disable Two-Factor Authentication" %}</h3> <h3 class="title is-5">{% trans "Disable Two-Factor Authentication" %}</h3>
<p class="subtitle">{% blocktrans trimmed %}However we strongly discourage you to do so, you can <p class="subtitle">{% blocktrans trimmed %}However we strongly discourage you to do so, you can

View File

@@ -1,8 +1,9 @@
from __future__ import annotations from __future__ import annotations
from unittest.mock import patch
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from django.test import TestCase from django.test import TestCase
from unittest.mock import patch
from core.messaging.ai import run_prompt from core.messaging.ai import run_prompt
from core.models import AI, AIRunLog, User from core.models import AI, AIRunLog, User

View File

@@ -6,9 +6,9 @@ from django.test import TestCase
from core.models import ( from core.models import (
ChatSession, ChatSession,
ContactAvailabilityEvent, ContactAvailabilityEvent,
Message,
Person, Person,
PersonIdentifier, PersonIdentifier,
Message,
User, User,
) )
from core.presence.inference import now_ms from core.presence.inference import now_ms
@@ -16,7 +16,9 @@ from core.presence.inference import now_ms
class BackfillContactAvailabilityCommandTests(TestCase): class BackfillContactAvailabilityCommandTests(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user("backfill-user", "backfill@example.com", "x") self.user = User.objects.create_user(
"backfill-user", "backfill@example.com", "x"
)
self.person = Person.objects.create(user=self.user, name="Backfill Person") self.person = Person.objects.create(user=self.user, name="Backfill Person")
self.identifier = PersonIdentifier.objects.create( self.identifier = PersonIdentifier.objects.create(
user=self.user, user=self.user,
@@ -24,7 +26,9 @@ class BackfillContactAvailabilityCommandTests(TestCase):
service="signal", service="signal",
identifier="+15551234567", identifier="+15551234567",
) )
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier) self.session = ChatSession.objects.create(
user=self.user, identifier=self.identifier
)
def test_backfill_creates_message_and_read_receipt_availability_events(self): def test_backfill_creates_message_and_read_receipt_availability_events(self):
base_ts = now_ms() base_ts = now_ms()
@@ -58,7 +62,9 @@ class BackfillContactAvailabilityCommandTests(TestCase):
) )
events = list( events = list(
ContactAvailabilityEvent.objects.filter(user=self.user).order_by("ts", "source_kind") ContactAvailabilityEvent.objects.filter(user=self.user).order_by(
"ts", "source_kind"
)
) )
self.assertEqual(3, len(events)) self.assertEqual(3, len(events))
self.assertTrue(any(row.source_kind == "message_in" for row in events)) self.assertTrue(any(row.source_kind == "message_in" for row in events))

View File

@@ -123,7 +123,9 @@ class BPFallbackTests(TransactionTestCase):
run = CommandRun.objects.get(trigger_message=trigger, profile=self.profile) run = CommandRun.objects.get(trigger_message=trigger, profile=self.profile)
self.assertEqual("failed", run.status) self.assertEqual("failed", run.status)
self.assertIn("bp_ai_failed", str(run.error)) self.assertIn("bp_ai_failed", str(run.error))
self.assertFalse(BusinessPlanDocument.objects.filter(trigger_message=trigger).exists()) self.assertFalse(
BusinessPlanDocument.objects.filter(trigger_message=trigger).exists()
)
def test_bp_uses_same_ai_selection_order_as_compose(self): def test_bp_uses_same_ai_selection_order_as_compose(self):
AI.objects.create( AI.objects.create(

View File

@@ -35,7 +35,9 @@ class BPSubcommandTests(TransactionTestCase):
service="whatsapp", service="whatsapp",
identifier="120363402761690215", identifier="120363402761690215",
) )
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier) self.session = ChatSession.objects.create(
user=self.user, identifier=self.identifier
)
self.profile = CommandProfile.objects.create( self.profile = CommandProfile.objects.create(
user=self.user, user=self.user,
slug="bp", slug="bp",
@@ -96,13 +98,19 @@ class BPSubcommandTests(TransactionTestCase):
source_service="whatsapp", source_service="whatsapp",
source_chat_id="120363402761690215", source_chat_id="120363402761690215",
) )
with patch("core.commands.handlers.bp.ai_runner.run_prompt", new=AsyncMock()) as mocked_ai: with patch(
result = async_to_sync(BPCommandHandler().execute)(self._ctx(trigger, trigger.text)) "core.commands.handlers.bp.ai_runner.run_prompt", new=AsyncMock()
) as mocked_ai:
result = async_to_sync(BPCommandHandler().execute)(
self._ctx(trigger, trigger.text)
)
self.assertTrue(result.ok) self.assertTrue(result.ok)
mocked_ai.assert_not_awaited() mocked_ai.assert_not_awaited()
doc = BusinessPlanDocument.objects.get(trigger_message=trigger) doc = BusinessPlanDocument.objects.get(trigger_message=trigger)
self.assertEqual("direct body", doc.content_markdown) self.assertEqual("direct body", doc.content_markdown)
self.assertEqual("Generated from 1 message.", doc.structured_payload.get("annotation")) self.assertEqual(
"Generated from 1 message.", doc.structured_payload.get("annotation")
)
def test_set_reply_only_uses_anchor(self): def test_set_reply_only_uses_anchor(self):
anchor = Message.objects.create( anchor = Message.objects.create(
@@ -124,11 +132,15 @@ class BPSubcommandTests(TransactionTestCase):
source_chat_id="120363402761690215", source_chat_id="120363402761690215",
reply_to=anchor, reply_to=anchor,
) )
result = async_to_sync(BPCommandHandler().execute)(self._ctx(trigger, trigger.text)) result = async_to_sync(BPCommandHandler().execute)(
self._ctx(trigger, trigger.text)
)
self.assertTrue(result.ok) self.assertTrue(result.ok)
doc = BusinessPlanDocument.objects.get(trigger_message=trigger) doc = BusinessPlanDocument.objects.get(trigger_message=trigger)
self.assertEqual("anchor body", doc.content_markdown) self.assertEqual("anchor body", doc.content_markdown)
self.assertEqual("Generated from 1 message.", doc.structured_payload.get("annotation")) self.assertEqual(
"Generated from 1 message.", doc.structured_payload.get("annotation")
)
def test_set_reply_plus_addendum_uses_divider(self): def test_set_reply_plus_addendum_uses_divider(self):
anchor = Message.objects.create( anchor = Message.objects.create(
@@ -150,7 +162,9 @@ class BPSubcommandTests(TransactionTestCase):
source_chat_id="120363402761690215", source_chat_id="120363402761690215",
reply_to=anchor, reply_to=anchor,
) )
result = async_to_sync(BPCommandHandler().execute)(self._ctx(trigger, trigger.text)) result = async_to_sync(BPCommandHandler().execute)(
self._ctx(trigger, trigger.text)
)
self.assertTrue(result.ok) self.assertTrue(result.ok)
doc = BusinessPlanDocument.objects.get(trigger_message=trigger) doc = BusinessPlanDocument.objects.get(trigger_message=trigger)
self.assertIn("base body", doc.content_markdown) self.assertIn("base body", doc.content_markdown)
@@ -171,7 +185,9 @@ class BPSubcommandTests(TransactionTestCase):
source_service="whatsapp", source_service="whatsapp",
source_chat_id="120363402761690215", source_chat_id="120363402761690215",
) )
result = async_to_sync(BPCommandHandler().execute)(self._ctx(trigger, trigger.text)) result = async_to_sync(BPCommandHandler().execute)(
self._ctx(trigger, trigger.text)
)
self.assertFalse(result.ok) self.assertFalse(result.ok)
self.assertEqual("failed", result.status) self.assertEqual("failed", result.status)
self.assertEqual("bp_set_range_requires_reply_target", result.error) self.assertEqual("bp_set_range_requires_reply_target", result.error)
@@ -205,8 +221,12 @@ class BPSubcommandTests(TransactionTestCase):
source_chat_id="120363402761690215", source_chat_id="120363402761690215",
reply_to=anchor, reply_to=anchor,
) )
result = async_to_sync(BPCommandHandler().execute)(self._ctx(trigger, trigger.text)) result = async_to_sync(BPCommandHandler().execute)(
self._ctx(trigger, trigger.text)
)
self.assertTrue(result.ok) self.assertTrue(result.ok)
doc = BusinessPlanDocument.objects.get(trigger_message=trigger) doc = BusinessPlanDocument.objects.get(trigger_message=trigger)
self.assertEqual("line 1\n(no text)\n#bp set range#", doc.content_markdown) self.assertEqual("line 1\n(no text)\n#bp set range#", doc.content_markdown)
self.assertEqual("Generated from 3 messages.", doc.structured_payload.get("annotation")) self.assertEqual(
"Generated from 3 messages.", doc.structured_payload.get("annotation")
)

View File

@@ -55,7 +55,9 @@ class ClaudeCLITaskProviderTests(SimpleTestCase):
@patch("core.tasks.providers.claude_cli.subprocess.run") @patch("core.tasks.providers.claude_cli.subprocess.run")
def test_timeout_maps_to_failed_result(self, run_mock): def test_timeout_maps_to_failed_result(self, run_mock):
run_mock.side_effect = TimeoutExpired(cmd=["claude"], timeout=10) run_mock.side_effect = TimeoutExpired(cmd=["claude"], timeout=10)
result = self.provider.append_update({"command": "claude", "timeout_seconds": 10}, {"task_id": "t1"}) result = self.provider.append_update(
{"command": "claude", "timeout_seconds": 10}, {"task_id": "t1"}
)
self.assertFalse(result.ok) self.assertFalse(result.ok)
self.assertIn("timeout", result.error) self.assertIn("timeout", result.error)
@@ -70,7 +72,9 @@ class ClaudeCLITaskProviderTests(SimpleTestCase):
result = self.provider.append_update({"command": "claude"}, {"task_id": "t1"}) result = self.provider.append_update({"command": "claude"}, {"task_id": "t1"})
self.assertTrue(result.ok) self.assertTrue(result.ok)
self.assertTrue(bool((result.payload or {}).get("requires_approval"))) self.assertTrue(bool((result.payload or {}).get("requires_approval")))
self.assertEqual("requires_approval", (result.payload or {}).get("parsed_status")) self.assertEqual(
"requires_approval", (result.payload or {}).get("parsed_status")
)
@patch("core.tasks.providers.claude_cli.subprocess.run") @patch("core.tasks.providers.claude_cli.subprocess.run")
def test_retries_with_positional_op_when_flag_unsupported(self, run_mock): def test_retries_with_positional_op_when_flag_unsupported(self, run_mock):
@@ -99,7 +103,9 @@ class ClaudeCLITaskProviderTests(SimpleTestCase):
self.assertEqual(["claude", "task-sync", "create"], second[:3]) self.assertEqual(["claude", "task-sync", "create"], second[:3])
@patch("core.tasks.providers.claude_cli.subprocess.run") @patch("core.tasks.providers.claude_cli.subprocess.run")
def test_falls_back_to_builtin_approval_stub_when_no_task_sync_contract(self, run_mock): def test_falls_back_to_builtin_approval_stub_when_no_task_sync_contract(
self, run_mock
):
run_mock.side_effect = [ run_mock.side_effect = [
CompletedProcess( CompletedProcess(
args=[], args=[],
@@ -124,8 +130,13 @@ class ClaudeCLITaskProviderTests(SimpleTestCase):
) )
self.assertTrue(result.ok) self.assertTrue(result.ok)
self.assertTrue(bool((result.payload or {}).get("requires_approval"))) self.assertTrue(bool((result.payload or {}).get("requires_approval")))
self.assertEqual("requires_approval", str((result.payload or {}).get("status") or "")) self.assertEqual(
self.assertEqual("builtin_task_sync_stub", str((result.payload or {}).get("fallback_mode") or "")) "requires_approval", str((result.payload or {}).get("status") or "")
)
self.assertEqual(
"builtin_task_sync_stub",
str((result.payload or {}).get("fallback_mode") or ""),
)
@patch("core.tasks.providers.claude_cli.subprocess.run") @patch("core.tasks.providers.claude_cli.subprocess.run")
def test_builtin_stub_approval_response_returns_ok(self, run_mock): def test_builtin_stub_approval_response_returns_ok(self, run_mock):

View File

@@ -8,10 +8,10 @@ from core.commands.engine import process_inbound_message
from core.commands.handlers.claude import parse_claude_command from core.commands.handlers.claude import parse_claude_command
from core.models import ( from core.models import (
ChatSession, ChatSession,
CommandChannelBinding,
CommandProfile,
CodexPermissionRequest, CodexPermissionRequest,
CodexRun, CodexRun,
CommandChannelBinding,
CommandProfile,
DerivedTask, DerivedTask,
ExternalSyncEvent, ExternalSyncEvent,
Message, Message,
@@ -45,7 +45,9 @@ class ClaudeCommandParserTests(TestCase):
class ClaudeCommandExecutionTests(TestCase): class ClaudeCommandExecutionTests(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user("claude-cmd-user", "claude-cmd@example.com", "x") self.user = User.objects.create_user(
"claude-cmd-user", "claude-cmd@example.com", "x"
)
self.person = Person.objects.create(user=self.user, name="Claude Cmd") self.person = Person.objects.create(user=self.user, name="Claude Cmd")
self.identifier = PersonIdentifier.objects.create( self.identifier = PersonIdentifier.objects.create(
user=self.user, user=self.user,
@@ -53,7 +55,9 @@ class ClaudeCommandExecutionTests(TestCase):
service="web", service="web",
identifier="web-chan-1", identifier="web-chan-1",
) )
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier) self.session = ChatSession.objects.create(
user=self.user, identifier=self.identifier
)
self.project = TaskProject.objects.create(user=self.user, name="Project A") self.project = TaskProject.objects.create(user=self.user, name="Project A")
self.task = DerivedTask.objects.create( self.task = DerivedTask.objects.create(
user=self.user, user=self.user,
@@ -202,7 +206,9 @@ class ClaudeCommandExecutionTests(TestCase):
channel_identifier="approver-chan", channel_identifier="approver-chan",
enabled=True, enabled=True,
) )
trigger = self._msg("#claude approve cl-ak-123#", source_chat_id="approver-chan") trigger = self._msg(
"#claude approve cl-ak-123#", source_chat_id="approver-chan"
)
results = async_to_sync(process_inbound_message)( results = async_to_sync(process_inbound_message)(
CommandContext( CommandContext(
service="web", service="web",

View File

@@ -55,7 +55,9 @@ class CodexCLITaskProviderTests(SimpleTestCase):
@patch("core.tasks.providers.codex_cli.subprocess.run") @patch("core.tasks.providers.codex_cli.subprocess.run")
def test_timeout_maps_to_failed_result(self, run_mock): def test_timeout_maps_to_failed_result(self, run_mock):
run_mock.side_effect = TimeoutExpired(cmd=["codex"], timeout=10) run_mock.side_effect = TimeoutExpired(cmd=["codex"], timeout=10)
result = self.provider.append_update({"command": "codex", "timeout_seconds": 10}, {"task_id": "t1"}) result = self.provider.append_update(
{"command": "codex", "timeout_seconds": 10}, {"task_id": "t1"}
)
self.assertFalse(result.ok) self.assertFalse(result.ok)
self.assertIn("timeout", result.error) self.assertIn("timeout", result.error)
@@ -70,7 +72,9 @@ class CodexCLITaskProviderTests(SimpleTestCase):
result = self.provider.append_update({"command": "codex"}, {"task_id": "t1"}) result = self.provider.append_update({"command": "codex"}, {"task_id": "t1"})
self.assertTrue(result.ok) self.assertTrue(result.ok)
self.assertTrue(bool((result.payload or {}).get("requires_approval"))) self.assertTrue(bool((result.payload or {}).get("requires_approval")))
self.assertEqual("requires_approval", (result.payload or {}).get("parsed_status")) self.assertEqual(
"requires_approval", (result.payload or {}).get("parsed_status")
)
@patch("core.tasks.providers.codex_cli.subprocess.run") @patch("core.tasks.providers.codex_cli.subprocess.run")
def test_retries_with_positional_op_when_flag_unsupported(self, run_mock): def test_retries_with_positional_op_when_flag_unsupported(self, run_mock):
@@ -99,7 +103,9 @@ class CodexCLITaskProviderTests(SimpleTestCase):
self.assertEqual(["codex", "task-sync", "create"], second[:3]) self.assertEqual(["codex", "task-sync", "create"], second[:3])
@patch("core.tasks.providers.codex_cli.subprocess.run") @patch("core.tasks.providers.codex_cli.subprocess.run")
def test_falls_back_to_builtin_approval_stub_when_no_task_sync_contract(self, run_mock): def test_falls_back_to_builtin_approval_stub_when_no_task_sync_contract(
self, run_mock
):
run_mock.side_effect = [ run_mock.side_effect = [
CompletedProcess( CompletedProcess(
args=[], args=[],
@@ -124,8 +130,13 @@ class CodexCLITaskProviderTests(SimpleTestCase):
) )
self.assertTrue(result.ok) self.assertTrue(result.ok)
self.assertTrue(bool((result.payload or {}).get("requires_approval"))) self.assertTrue(bool((result.payload or {}).get("requires_approval")))
self.assertEqual("requires_approval", str((result.payload or {}).get("status") or "")) self.assertEqual(
self.assertEqual("builtin_task_sync_stub", str((result.payload or {}).get("fallback_mode") or "")) "requires_approval", str((result.payload or {}).get("status") or "")
)
self.assertEqual(
"builtin_task_sync_stub",
str((result.payload or {}).get("fallback_mode") or ""),
)
@patch("core.tasks.providers.codex_cli.subprocess.run") @patch("core.tasks.providers.codex_cli.subprocess.run")
def test_builtin_stub_approval_response_returns_ok(self, run_mock): def test_builtin_stub_approval_response_returns_ok(self, run_mock):

View File

@@ -8,10 +8,10 @@ from core.commands.engine import process_inbound_message
from core.commands.handlers.codex import parse_codex_command from core.commands.handlers.codex import parse_codex_command
from core.models import ( from core.models import (
ChatSession, ChatSession,
CommandChannelBinding,
CommandProfile,
CodexPermissionRequest, CodexPermissionRequest,
CodexRun, CodexRun,
CommandChannelBinding,
CommandProfile,
DerivedTask, DerivedTask,
ExternalSyncEvent, ExternalSyncEvent,
Message, Message,
@@ -41,7 +41,9 @@ class CodexCommandParserTests(TestCase):
class CodexCommandExecutionTests(TestCase): class CodexCommandExecutionTests(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user("codex-cmd-user", "codex-cmd@example.com", "x") self.user = User.objects.create_user(
"codex-cmd-user", "codex-cmd@example.com", "x"
)
self.person = Person.objects.create(user=self.user, name="Codex Cmd") self.person = Person.objects.create(user=self.user, name="Codex Cmd")
self.identifier = PersonIdentifier.objects.create( self.identifier = PersonIdentifier.objects.create(
user=self.user, user=self.user,
@@ -49,7 +51,9 @@ class CodexCommandExecutionTests(TestCase):
service="web", service="web",
identifier="web-chan-1", identifier="web-chan-1",
) )
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier) self.session = ChatSession.objects.create(
user=self.user, identifier=self.identifier
)
self.project = TaskProject.objects.create(user=self.user, name="Project A") self.project = TaskProject.objects.create(user=self.user, name="Project A")
self.task = DerivedTask.objects.create( self.task = DerivedTask.objects.create(
user=self.user, user=self.user,
@@ -126,7 +130,10 @@ class CodexCommandExecutionTests(TestCase):
self.assertEqual("waiting_approval", run.status) self.assertEqual("waiting_approval", run.status)
event = ExternalSyncEvent.objects.order_by("-created_at").first() event = ExternalSyncEvent.objects.order_by("-created_at").first()
self.assertEqual("waiting_approval", event.status) self.assertEqual("waiting_approval", event.status)
self.assertEqual("default", str((event.payload or {}).get("provider_payload", {}).get("mode") or "")) self.assertEqual(
"default",
str((event.payload or {}).get("provider_payload", {}).get("mode") or ""),
)
self.assertTrue( self.assertTrue(
CodexPermissionRequest.objects.filter( CodexPermissionRequest.objects.filter(
user=self.user, user=self.user,
@@ -167,7 +174,10 @@ class CodexCommandExecutionTests(TestCase):
source_service="web", source_service="web",
source_channel="web-chan-1", source_channel="web-chan-1",
status="waiting_approval", status="waiting_approval",
request_payload={"action": "append_update", "provider_payload": {"task_id": str(self.task.id)}}, request_payload={
"action": "append_update",
"provider_payload": {"task_id": str(self.task.id)},
},
result_payload={}, result_payload={},
) )
req = CodexPermissionRequest.objects.create( req = CodexPermissionRequest.objects.create(
@@ -207,7 +217,9 @@ class CodexCommandExecutionTests(TestCase):
self.assertEqual("approved_waiting_resume", run.status) self.assertEqual("approved_waiting_resume", run.status)
self.assertEqual("ok", waiting_event.status) self.assertEqual("ok", waiting_event.status)
self.assertTrue( self.assertTrue(
ExternalSyncEvent.objects.filter(idempotency_key="codex_approval:ak-123:approved", status="pending").exists() ExternalSyncEvent.objects.filter(
idempotency_key="codex_approval:ak-123:approved", status="pending"
).exists()
) )
def test_approve_pre_submit_request_queues_original_action(self): def test_approve_pre_submit_request_queues_original_action(self):
@@ -226,7 +238,10 @@ class CodexCommandExecutionTests(TestCase):
source_service="web", source_service="web",
source_channel="web-chan-1", source_channel="web-chan-1",
status="waiting_approval", status="waiting_approval",
request_payload={"action": "append_update", "provider_payload": {"task_id": str(self.task.id)}}, request_payload={
"action": "append_update",
"provider_payload": {"task_id": str(self.task.id)},
},
result_payload={}, result_payload={},
) )
CodexPermissionRequest.objects.create( CodexPermissionRequest.objects.create(
@@ -264,7 +279,11 @@ class CodexCommandExecutionTests(TestCase):
) )
self.assertEqual(1, len(results)) self.assertEqual(1, len(results))
self.assertTrue(results[0].ok) self.assertTrue(results[0].ok)
resume = ExternalSyncEvent.objects.filter(idempotency_key="codex_cmd:resume:1").first() resume = ExternalSyncEvent.objects.filter(
idempotency_key="codex_cmd:resume:1"
).first()
self.assertIsNotNone(resume) self.assertIsNotNone(resume)
self.assertEqual("pending", resume.status) self.assertEqual("pending", resume.status)
self.assertEqual("append_update", str((resume.payload or {}).get("action") or "")) self.assertEqual(
"append_update", str((resume.payload or {}).get("action") or "")
)

View File

@@ -5,13 +5,22 @@ from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from core.management.commands.codex_worker import Command as CodexWorkerCommand from core.management.commands.codex_worker import Command as CodexWorkerCommand
from core.models import CodexPermissionRequest, CodexRun, ExternalSyncEvent, TaskProject, TaskProviderConfig, User from core.models import (
CodexPermissionRequest,
CodexRun,
ExternalSyncEvent,
TaskProject,
TaskProviderConfig,
User,
)
from core.tasks.providers.base import ProviderResult from core.tasks.providers.base import ProviderResult
class CodexWorkerPhase1Tests(TestCase): class CodexWorkerPhase1Tests(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user("codex-worker-user", "codex-worker@example.com", "x") self.user = User.objects.create_user(
"codex-worker-user", "codex-worker@example.com", "x"
)
self.project = TaskProject.objects.create(user=self.user, name="Worker Project") self.project = TaskProject.objects.create(user=self.user, name="Worker Project")
self.cfg = TaskProviderConfig.objects.create( self.cfg = TaskProviderConfig.objects.create(
user=self.user, user=self.user,
@@ -57,7 +66,9 @@ class CodexWorkerPhase1Tests(TestCase):
run_in_worker = True run_in_worker = True
def append_update(self, config, payload): def append_update(self, config, payload):
return ProviderResult(ok=True, payload={"status": "ok", "summary": "done"}) return ProviderResult(
ok=True, payload={"status": "ok", "summary": "done"}
)
create_task = mark_complete = link_task = append_update create_task = mark_complete = link_task = append_update
@@ -71,7 +82,9 @@ class CodexWorkerPhase1Tests(TestCase):
self.assertEqual("done", str(run.result_payload.get("summary") or "")) self.assertEqual("done", str(run.result_payload.get("summary") or ""))
@patch("core.management.commands.codex_worker.get_provider") @patch("core.management.commands.codex_worker.get_provider")
def test_requires_approval_moves_to_waiting_and_creates_permission_request(self, get_provider_mock): def test_requires_approval_moves_to_waiting_and_creates_permission_request(
self, get_provider_mock
):
run = CodexRun.objects.create( run = CodexRun.objects.create(
user=self.user, user=self.user,
project=self.project, project=self.project,
@@ -128,7 +141,10 @@ class CodexWorkerPhase1Tests(TestCase):
user=self.user, user=self.user,
provider="codex_cli", provider="codex_cli",
status="waiting_approval", status="waiting_approval",
payload={"action": "append_update", "provider_payload": {"mode": "default"}}, payload={
"action": "append_update",
"provider_payload": {"mode": "default"},
},
error="", error="",
) )
run = CodexRun.objects.create( run = CodexRun.objects.create(
@@ -169,7 +185,9 @@ class CodexWorkerPhase1Tests(TestCase):
run_in_worker = True run_in_worker = True
def append_update(self, config, payload): def append_update(self, config, payload):
return ProviderResult(ok=True, payload={"status": "ok", "summary": "resumed"}) return ProviderResult(
ok=True, payload={"status": "ok", "summary": "resumed"}
)
create_task = mark_complete = link_task = append_update create_task = mark_complete = link_task = append_update

View File

@@ -89,7 +89,9 @@ class CommandSecurityPolicyTests(TestCase):
) )
self.assertEqual(1, len(results)) self.assertEqual(1, len(results))
self.assertEqual("skipped", results[0].status) self.assertEqual("skipped", results[0].status)
self.assertTrue(str(results[0].error).startswith("policy_denied:service_not_allowed")) self.assertTrue(
str(results[0].error).startswith("policy_denied:service_not_allowed")
)
def test_gateway_scope_can_require_trusted_omemo_key(self): def test_gateway_scope_can_require_trusted_omemo_key(self):
CommandSecurityPolicy.objects.create( CommandSecurityPolicy.objects.create(
@@ -120,7 +122,9 @@ class CommandSecurityPolicyTests(TestCase):
channel_identifier="policy-user@zm.is", channel_identifier="policy-user@zm.is",
sender_identifier="policy-user@zm.is/phone", sender_identifier="policy-user@zm.is/phone",
message_text=".tasks list", message_text=".tasks list",
message_meta={"xmpp": {"omemo_status": "detected", "omemo_client_key": "sid:abc"}}, message_meta={
"xmpp": {"omemo_status": "detected", "omemo_client_key": "sid:abc"}
},
payload={}, payload={},
), ),
routes=[ routes=[

View File

@@ -9,8 +9,8 @@ from core.commands.base import CommandContext
from core.commands.handlers.bp import BPCommandHandler from core.commands.handlers.bp import BPCommandHandler
from core.commands.policies import ensure_variant_policies_for_profile from core.commands.policies import ensure_variant_policies_for_profile
from core.models import ( from core.models import (
BusinessPlanDocument,
AI, AI,
BusinessPlanDocument,
ChatSession, ChatSession,
CommandAction, CommandAction,
CommandChannelBinding, CommandChannelBinding,
@@ -37,7 +37,9 @@ class CommandVariantPolicyTests(TransactionTestCase):
service="whatsapp", service="whatsapp",
identifier="120363402761690215", identifier="120363402761690215",
) )
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier) self.session = ChatSession.objects.create(
user=self.user, identifier=self.identifier
)
self.profile = CommandProfile.objects.create( self.profile = CommandProfile.objects.create(
user=self.user, user=self.user,
slug="bp", slug="bp",
@@ -109,7 +111,9 @@ class CommandVariantPolicyTests(TransactionTestCase):
def test_bp_primary_can_run_in_verbatim_mode_without_ai(self): def test_bp_primary_can_run_in_verbatim_mode_without_ai(self):
ensure_variant_policies_for_profile(self.profile) ensure_variant_policies_for_profile(self.profile)
policy = CommandVariantPolicy.objects.get(profile=self.profile, variant_key="bp") policy = CommandVariantPolicy.objects.get(
profile=self.profile, variant_key="bp"
)
policy.generation_mode = "verbatim" policy.generation_mode = "verbatim"
policy.send_plan_to_egress = False policy.send_plan_to_egress = False
policy.send_status_to_source = False policy.send_status_to_source = False
@@ -143,7 +147,9 @@ class CommandVariantPolicyTests(TransactionTestCase):
def test_bp_set_ai_mode_ignores_template(self): def test_bp_set_ai_mode_ignores_template(self):
ensure_variant_policies_for_profile(self.profile) ensure_variant_policies_for_profile(self.profile)
policy = CommandVariantPolicy.objects.get(profile=self.profile, variant_key="bp_set") policy = CommandVariantPolicy.objects.get(
profile=self.profile, variant_key="bp_set"
)
policy.generation_mode = "ai" policy.generation_mode = "ai"
policy.send_plan_to_egress = False policy.send_plan_to_egress = False
policy.send_status_to_source = False policy.send_status_to_source = False
@@ -222,4 +228,6 @@ class CommandVariantPolicyTests(TransactionTestCase):
self.assertTrue(result.ok) self.assertTrue(result.ok)
source_status.assert_awaited() source_status.assert_awaited()
self.assertEqual(1, binding_send.await_count) self.assertEqual(1, binding_send.await_count)
self.assertFalse(BusinessPlanDocument.objects.filter(trigger_message=trigger).exists()) self.assertFalse(
BusinessPlanDocument.objects.filter(trigger_message=trigger).exists()
)

View File

@@ -13,7 +13,9 @@ class ComposeReactTests(TestCase):
self.user = User.objects.create_user("compose-react", "react@example.com", "pw") self.user = User.objects.create_user("compose-react", "react@example.com", "pw")
self.client.force_login(self.user) self.client.force_login(self.user)
def _build_message(self, *, service: str, identifier: str, source_message_id: str = ""): def _build_message(
self, *, service: str, identifier: str, source_message_id: str = ""
):
person = Person.objects.create(user=self.user, name=f"{service} person") person = Person.objects.create(user=self.user, name=f"{service} person")
person_identifier = PersonIdentifier.objects.create( person_identifier = PersonIdentifier.objects.create(
user=self.user, user=self.user,

Some files were not shown because too many files have changed in this diff Show More